import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { AccessMode } from '@/models/access-control' import * as exploreService from '@/service/explore' import { renderWithConsoleQuery } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import { AppCard } from '../app-card' import { StarredAppCard } from '../starred-app-card' let mockWebappAuthEnabled = false let mockRbacEnabled = true const mockUserCanAccessApp = vi.hoisted(() => ({ result: true as boolean | undefined, isLoading: false, })) const mockAppDslExport = vi.hoisted(() => ({ exportAppDsl: vi.fn(), isExporting: false, })) const mockWorkflowAppDslExport = vi.hoisted(() => ({ exportWorkflowAppDsl: vi.fn(), isExporting: false, })) const mockCopyApp = vi.hoisted(() => vi.fn((_variables: unknown): Promise => Promise.resolve({ id: 'new-app-id', mode: 'chat', maintainer: 'user-1', permission_keys: [], }), ), ) const mockUpdateAppMutation = vi.hoisted(() => vi.fn((_variables: unknown): Promise => Promise.resolve()), ) const mockDeleteAppMutation = vi.hoisted(() => vi.fn((_variables: unknown): Promise => Promise.resolve()), ) const mockStarAppMutation = vi.hoisted(() => vi.fn((_variables: unknown): Promise => Promise.resolve()), ) const mockUnstarAppMutation = vi.hoisted(() => vi.fn((_variables: unknown): Promise => Promise.resolve()), ) vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() const withMutation = (operation: object, mutationFn: typeof mockCopyApp) => new Proxy(operation, { get(target, property, receiver) { if (property === 'mutationOptions') return () => ({ mutationFn: (variables: unknown) => mutationFn(variables) }) return Reflect.get(target, property, receiver) }, }) const copy = new Proxy(actual.consoleQuery.apps.byAppId.copy, { get(target, property, receiver) { if (property === 'post') return withMutation(target.post, mockCopyApp) return Reflect.get(target, property, receiver) }, }) const star = new Proxy(actual.consoleQuery.apps.byAppId.star, { get(target, property, receiver) { if (property === 'post') return withMutation(target.post, mockStarAppMutation) if (property === 'delete') return withMutation(target.delete, mockUnstarAppMutation) return Reflect.get(target, property, receiver) }, }) const byAppId = new Proxy(actual.consoleQuery.apps.byAppId, { get(target, property, receiver) { if (property === 'copy') return copy if (property === 'put') return withMutation(target.put, mockUpdateAppMutation) if (property === 'delete') return withMutation(target.delete, mockDeleteAppMutation) if (property === 'star') return star return Reflect.get(target, property, receiver) }, }) const apps = new Proxy(actual.consoleQuery.apps, { get(target, property, receiver) { if (property === 'byAppId') return byAppId return Reflect.get(target, property, receiver) }, }) return { ...actual, consoleQuery: new Proxy(actual.consoleQuery, { get(target, property, receiver) { if (property === 'apps') return apps return Reflect.get(target, property, receiver) }, }), } }) vi.mock('@/app/components/app/use-export-app-dsl', () => ({ useExportAppDsl: () => mockAppDslExport, useExportWorkflowAppDsl: () => mockWorkflowAppDslExport, })) const getOperationsTrigger = () => screen.getByRole('button', { name: /common\.operation\.moreActionsFor/ }) // Mock next/navigation const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), })) const toastMocks = vi.hoisted(() => { const record = vi.fn() const api = vi.fn((message: unknown, options?: Record) => record({ message, ...options }), ) return { record, api: Object.assign(api, { success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options }), ), error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options }), ), warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options }), ), info: vi.fn((message: unknown, options?: Record) => record({ type: 'info', message, ...options }), ), dismiss: vi.fn(), update: vi.fn(), promise: vi.fn(), }), } }) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMocks.api, })) // Mock use-context-selector with stable toast reference for tracking calls // Include createContext for components that use it (like Toast) vi.mock('use-context-selector', () => ({ createContext: (defaultValue: T) => React.createContext(defaultValue), useContext: () => ({ notify: toastMocks.api, }), useContextSelector: (_context: unknown, selector: (state: Record) => unknown) => selector({ notify: toastMocks.api, }), })) const mockConsoleState = vi.hoisted(() => ({ isCurrentWorkspaceEditor: true, userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'] as string[], })) const render = (ui: React.ReactElement) => renderWithConsoleQuery(ui, { accountProfile: mockConsoleState.userProfile, systemFeatures: { webapp_auth: { enabled: mockWebappAuthEnabled }, branding: { enabled: false }, rbac_enabled: mockRbacEnabled, }, }) // Mock app context vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => mockConsoleState) }) vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') return createPermissionStateModuleMock(() => mockConsoleState) }) // Mock provider context const mockOnPlanInfoChanged = vi.fn() vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged, }), })) // systemFeatures is seeded into the QueryClient via the local render helper. vi.mock('@/service/apps', () => ({ deleteApp: vi.fn(() => Promise.resolve()), })) vi.mock('@/service/explore', () => ({ fetchInstalledAppList: vi.fn(() => Promise.resolve({ installed_apps: [{ id: 'installed-1' }] })), })) vi.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: () => ({ data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, isLoading: mockUserCanAccessApp.isLoading, }), })) vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, isLoading: mockUserCanAccessApp.isLoading, }), })) // Mock hooks const mockOpenAsyncWindow = vi.fn() vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => mockOpenAsyncWindow, })) // Mock utils const { mockGetRedirection } = vi.hoisted(() => ({ mockGetRedirection: vi.fn(), })) vi.mock('@/utils/app-redirection', () => ({ getRedirectionPath: (app: { id: string }) => `/app/${app.id}/configuration`, getRedirection: mockGetRedirection, })) vi.mock('@/utils/var', () => ({ basePath: '', })) vi.mock('@/utils/time', () => ({ formatTime: () => 'Jan 1, 2024', })) // Mock dynamic imports vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise) => { void importFn().catch(() => {}) const fnString = importFn.toString() if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) { return function MockEditAppModal({ show, onHide, onConfirm, }: { show: boolean onHide: () => void onConfirm?: (data: Record) => void }) { 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', ), ) } } if (fnString.includes('duplicate-modal')) { return function MockDuplicateAppModal({ show, onHide, onConfirm, }: { show: boolean onHide: () => void onConfirm?: (data: Record) => void }) { 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', ), ) } } if (fnString.includes('switch-app-modal')) { return function MockSwitchAppModal({ show, onClose, }: { show: boolean onClose: () => void }) { if (!show) return null return React.createElement( 'div', { 'data-testid': 'switch-modal' }, React.createElement( 'button', { onClick: onClose, 'data-testid': 'close-switch-modal' }, 'Close', ), ) } } if (fnString.includes('dsl-export-confirm-modal')) { return function MockDSLExportModal({ onClose, onConfirm, }: { onClose?: () => void onConfirm?: (withSecrets: boolean) => void }) { 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', ), ) } } return () => null }, })) // AppCardTags has tag API dependencies - mock for isolated testing vi.mock('@/features/tag-management/components/app-card-tags', () => ({ AppCardTags: ({ tags, canBindOrUnbindTags, appName, }: { tags?: { id: string; name: string }[] canBindOrUnbindTags?: boolean appName: string }) => { return React.createElement( 'div', { 'aria-label': 'tag-selector', 'data-can-bind-or-unbind-tags': String(Boolean(canBindOrUnbindTags)), 'data-app-name': appName, }, tags?.map((tag: { id: string; name: string }) => React.createElement('span', { key: tag.id }, tag.name), ), ) }, })) // AppTypeIcon has complex icon mapping - mock for focused component testing vi.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) const createMockApp = (overrides: Partial = {}): AppPartial => ({ 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_by: 'user-1', maintainer: 'user-1', 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, permission_keys: [], ...overrides, }) describe('AppCard', () => { const mockApp = createMockApp() beforeEach(() => { vi.clearAllMocks() mockOpenAsyncWindow.mockReset() mockWebappAuthEnabled = false mockRbacEnabled = true mockUserCanAccessApp.result = true mockUserCanAccessApp.isLoading = false mockCopyApp.mockResolvedValue({ id: 'new-app-id', mode: 'chat', maintainer: 'user-1', permission_keys: [], }) mockAppDslExport.isExporting = false mockAppDslExport.exportAppDsl.mockResolvedValue({ status: 'downloaded' }) mockWorkflowAppDslExport.isExporting = false mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValue({ status: 'downloaded' }) mockConsoleState.isCurrentWorkspaceEditor = true mockConsoleState.userProfile = { id: 'user-1' } mockConsoleState.workspacePermissionKeys = ['app.create_and_management'] }) describe('Rendering', () => { it('should render preview-only app card as a dimmed information-only card', () => { const previewOnlyApp = createMockApp({ name: 'Preview Only App', description: 'Only visible metadata', author_name: 'Readonly Author', created_by: 'another-user', maintainer: 'another-user', tags: [{ id: 'tag-preview', name: 'Readonly Tag', type: 'app' as const }], permission_keys: [AppACLPermission.Preview], }) render() const card = screen.getByRole('button', { name: 'Preview Only App' }) expect(card).toHaveClass('opacity-60') expect(screen.getByRole('listitem')).toContainElement(card) expect(card).toHaveAccessibleDescription('Only visible metadata') expect(card).not.toHaveAttribute('aria-disabled') expect(screen.getByText('Only visible metadata')).toBeInTheDocument() expect(screen.getByText('Readonly Author')).toBeInTheDocument() const tagSelector = screen.getByLabelText('tag-selector') expect(tagSelector).toBeInTheDocument() expect(tagSelector).toHaveAttribute('data-can-bind-or-unbind-tags', 'false') expect(screen.queryByRole('link', { name: 'Preview Only App' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() expect( screen.queryByRole('button', { name: /common\.operation\.moreActionsFor/, }), ).not.toBeInTheDocument() fireEvent.click(tagSelector) expect(toastMocks.record).not.toHaveBeenCalled() fireEvent.click(card) expect(toastMocks.record).toHaveBeenCalledWith({ type: 'warning', message: 'app.noAccessResourcePermission', }) }) it('should render preview-only starred app card as a dimmed information-only card', () => { const previewOnlyApp = createMockApp({ name: 'Preview Only Starred App', author_name: 'Readonly Author', created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.Preview], }) render() const card = screen.getByRole('button', { name: 'Preview Only Starred App' }) expect(card).toHaveClass('opacity-60') expect(card).not.toHaveAttribute('aria-disabled') expect(screen.getByText('Readonly Author')).toBeInTheDocument() expect( screen.queryByRole('link', { name: 'Preview Only Starred App' }), ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument() expect( screen.queryByRole('button', { name: /common\.operation\.moreActionsFor/, }), ).not.toBeInTheDocument() fireEvent.click(card) expect(toastMocks.record).toHaveBeenCalledWith({ type: 'warning', message: 'app.noAccessResourcePermission', }) }) it('should display app name', () => { render() expect(screen.getByText('Test App')).toBeInTheDocument() }) it('should display app description', () => { render() expect(screen.getByText('Test app description')).toBeInTheDocument() }) it('should display author name', () => { render() expect(screen.getByText('Test Author')).toBeInTheDocument() }) it('should render app icon', () => { // AppIcon component renders the emoji icon from app data const { container } = render() const emojiIcon = container.querySelector(`em-emoji[id="${mockApp.icon}"]`) const imageIcon = container.querySelector('img') expect(emojiIcon || imageIcon).toBeTruthy() expect(emojiIcon?.parentElement).toHaveAttribute('aria-hidden', 'true') }) it('should treat a redundant image icon as decorative', () => { const imageApp = createMockApp({ icon_type: 'image', icon_url: 'https://example.com/app-icon.png', }) const { container } = render() expect(container.querySelector('img')).toHaveAttribute('alt', '') }) it('should render app type icon', () => { render() expect(screen.getByTestId('app-type-icon')).toBeInTheDocument() }) it('should display formatted edit time', () => { render() expect(screen.getByText(/edited/i)).toBeInTheDocument() }) }) describe('Props', () => { it('should handle different app modes', () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() }) it('should handle app with tags', () => { const appWithTags = { ...mockApp, tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const, binding_count: '' }], } render() // Verify the tag selector component renders expect(screen.getByLabelText('tag-selector')).toHaveAttribute('data-app-name', 'Test App') }) it('should display refreshed tag names from app props when tag ids stay the same', () => { const firstApp = createMockApp({ tags: [{ id: 'tag1', name: 'Old Tag', type: 'app' as const }], }) const refreshedApp = createMockApp({ tags: [{ id: 'tag1', name: 'New Tag', type: 'app' as const }], }) const { rerender } = render() expect(screen.getByText('Old Tag')).toBeInTheDocument() rerender() expect(screen.getByText('New Tag')).toBeInTheDocument() expect(screen.queryByText('Old Tag')).not.toBeInTheDocument() }) it('should allow app edit permission to bind tags without workspace tag management permission', () => { mockConsoleState.isCurrentWorkspaceEditor = false mockConsoleState.workspacePermissionKeys = [] mockConsoleState.userProfile = { id: 'user-2' } const editableApp = createMockApp({ maintainer: 'user-1', tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.Edit], }) render() expect(screen.getByLabelText('tag-selector')).toHaveAttribute( 'data-can-bind-or-unbind-tags', 'true', ) }) it('should allow workspace app tag management permission to bind tags without app edit permission', () => { mockConsoleState.isCurrentWorkspaceEditor = false mockConsoleState.workspacePermissionKeys = ['app.tag.manage'] mockConsoleState.userProfile = { id: 'user-2' } const tagManageApp = createMockApp({ maintainer: 'user-1', tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.ViewLayout], }) render() expect(screen.getByLabelText('tag-selector')).toHaveAttribute( 'data-can-bind-or-unbind-tags', 'true', ) }) it('should render existing app tags as readonly without app edit or workspace tag management permission', () => { mockConsoleState.isCurrentWorkspaceEditor = false mockConsoleState.workspacePermissionKeys = [] mockConsoleState.userProfile = { id: 'user-2' } const readonlyApp = createMockApp({ maintainer: 'user-1', tags: [{ id: 'tag1', name: 'Tag 1', type: 'app' as const }], permission_keys: [AppACLPermission.ViewLayout], }) render() expect(screen.getByLabelText('tag-selector')).toHaveAttribute( 'data-can-bind-or-unbind-tags', 'false', ) }) }) describe('Web app access control entry points', () => { it('should not render the access mode icon or tooltip trigger', () => { render() expect( screen.queryByRole('img', { name: 'app.accessItemsDescription.anyone' }), ).not.toBeInTheDocument() }) }) describe('Card Interaction', () => { it('should render card navigation as a link', () => { render() const cardLink = screen.getByRole('link', { name: 'Test App' }) expect(cardLink).toHaveAttribute('href', '/app/test-app-id/configuration') expect(cardLink).toHaveAccessibleName('Test App') expect(cardLink).toHaveAccessibleDescription('Test app description') expect(cardLink).toHaveAttribute('aria-describedby') expect(screen.getByRole('listitem')).toContainElement(cardLink) }) it('should keep card navigation and actions as sibling focus targets', async () => { const user = userEvent.setup() render() const cardLink = screen.getByRole('link', { name: 'Test App' }) const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) const operationsTrigger = getOperationsTrigger() expect(cardLink).not.toContainElement(starToggle) expect(cardLink).not.toContainElement(operationsTrigger) await user.tab() expect(cardLink).toHaveFocus() await user.tab() expect(starToggle).toHaveFocus() await user.tab() expect(operationsTrigger).toHaveFocus() }) it('should star the app from the card action without navigating', async () => { const user = userEvent.setup() render() const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'false') await user.click(starToggle) await waitFor(() => { expect(mockStarAppMutation).toHaveBeenCalledWith({ params: { app_id: mockApp.id }, }) }) expect(mockPush).not.toHaveBeenCalled() }) it('should unstar the app from the filled star action', async () => { const user = userEvent.setup() const starredApp = createMockApp({ is_starred: true }) render() const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' }) expect(starToggle).toHaveAttribute('aria-pressed', 'true') await user.hover(starToggle) expect(await screen.findByText('app.studio.starApp')).toBeInTheDocument() await user.click(starToggle) await waitFor(() => { expect(mockUnstarAppMutation).toHaveBeenCalledWith({ params: { app_id: starredApp.id }, }) }) }) }) describe('Operations Menu', () => { it('should show edit option when dropdown menu is opened', async () => { const user = userEvent.setup() render() await user.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() }) expect(mockPush).not.toHaveBeenCalled() }) it('should expose the same operations from the card context menu', async () => { const user = userEvent.setup() render() await user.pointer({ target: screen.getByRole('link', { name: 'Test App' }), keys: '[MouseRight]', }) expect(await screen.findByRole('menuitem', { name: 'app.editApp' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'app.duplicate' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'app.export' })).toBeInTheDocument() }) it('should keep card actions outside the card context menu trigger', async () => { const user = userEvent.setup() render() await user.pointer({ target: screen.getByRole('button', { name: 'app.studio.starApp: Test App' }), keys: '[MouseRight]', }) expect(screen.queryByRole('menuitem', { name: 'app.editApp' })).not.toBeInTheDocument() }) it('should show duplicate option when dropdown menu is opened', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.duplicate')).toBeInTheDocument() }) }) it('should show duplicate option when user can create apps without app import export permission', async () => { const appWithoutImportExportPermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.ViewLayout], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.duplicate')).toBeInTheDocument() }) expect(screen.queryByText('app.export')).not.toBeInTheDocument() }) it('should show duplicate option on starred cards when user can create apps without app import export permission', async () => { const appWithoutImportExportPermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.ViewLayout], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.duplicate')).toBeInTheDocument() }) expect(screen.queryByText('app.export')).not.toBeInTheDocument() }) it('should show export option when dropdown menu is opened', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.export')).toBeInTheDocument() }) }) it('should show delete option when dropdown menu is opened', async () => { render() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText(/switch/i)).toBeInTheDocument() }) }) it('should hide duplicate but keep app-authorized switch without app creation permission', async () => { mockConsoleState.workspacePermissionKeys = [] const editableChatApp = createMockApp({ created_by: 'another-user', maintainer: 'another-user', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.Edit], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText(/switch/i)).toBeInTheDocument() }) expect(screen.queryByText('app.duplicate')).not.toBeInTheDocument() }) it('should show switch option for completion mode apps', async () => { const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION } render() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.queryByText(/switch/i)).not.toBeInTheDocument() }) }) }) describe('Modal Interactions', () => { it('should open edit modal when edit button is clicked', async () => { render() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() }) it('should autofill the app name for delete confirmation', async () => { const user = userEvent.setup() render() await user.click(getOperationsTrigger()) await user.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) const deleteInput = await screen.findByRole('textbox') const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) expect(confirmButton).toBeDisabled() await user.click(screen.getByRole('button', { name: 'common.operation.fill' })) expect(deleteInput).toHaveValue(mockApp.name) expect(confirmButton).toBeEnabled() }) it('should close confirm dialog when cancel is clicked', async () => { render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) }) it('should not submit delete when confirmation text does not match', async () => { render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) const form = (await screen.findByRole('alertdialog')).querySelector('form') expect(form).toBeTruthy() fireEvent.submit(form!) expect(mockDeleteAppMutation).not.toHaveBeenCalled() }) it('should close edit modal when onHide is called', async () => { render() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) 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() }) }) it('should clear delete confirmation input after closing the dialog', async () => { render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) const deleteInput = await screen.findByRole('textbox') fireEvent.change(deleteInput, { target: { value: 'partial name' } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) await waitFor(() => { expect(screen.getByRole('textbox')).toHaveValue('') }) }) }) describe('API Callbacks', () => { it('should call deleteApp API when confirming delete', async () => { render() // Open dropdown menu and click delete fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() // Fill in the confirmation input with app name const deleteInput = screen.getByRole('textbox') fireEvent.change(deleteInput, { target: { value: mockApp.name } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() }) }) it('should handle delete failure', async () => { mockDeleteAppMutation.mockRejectedValueOnce(new Error('Delete failed')) render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() // Fill in the confirmation input with app name const deleteInput = screen.getByRole('textbox') fireEvent.change(deleteInput, { target: { value: mockApp.name } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Delete failed'), }) }) }) it('should handle delete failure without an error message', async () => { mockDeleteAppMutation.mockRejectedValueOnce({}) render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() fireEvent.change(screen.getByRole('textbox'), { target: { value: mockApp.name } }) fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.appDeleteFailed', }) }) }) it('should update the app and close the edit modal', async () => { render() fireEvent.click(getOperationsTrigger()) 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(mockUpdateAppMutation).toHaveBeenCalledWith({ params: { app_id: mockApp.id }, body: { name: 'Updated App', icon_type: 'emoji', icon: '🎯', icon_background: '#FFEAD5', description: 'Updated description', use_icon_as_answer_icon: false, max_active_requests: null, }, }) expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument() }) }) it('should call copyApp API when duplicating app', async () => { render() fireEvent.click(getOperationsTrigger()) 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(mockCopyApp).toHaveBeenCalled() }) }) it('should call onPlanInfoChanged after successful duplication', async () => { render() fireEvent.click(getOperationsTrigger()) 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 () => { mockCopyApp.mockRejectedValueOnce(new Error('Copy failed')) render() fireEvent.click(getOperationsTrigger()) 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(mockCopyApp).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed', }) }) }) it('should export the app DSL when exporting', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) expect(mockAppDslExport.exportAppDsl).toHaveBeenCalledWith({ appId: mockApp.id, appName: mockApp.name, }) }) it('should prevent duplicate exports while an app DSL export is pending', () => { mockAppDslExport.isExporting = true render() const trigger = screen.getByRole('button', { name: 'common.operation.exporting' }) expect(trigger).toBeDisabled() }) }) describe('Switch Modal', () => { it('should open switch modal when switch button is clicked', async () => { const chatApp = { ...mockApp, mode: AppModeEnum.CHAT } render() fireEvent.click(getOperationsTrigger()) 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() fireEvent.click(getOperationsTrigger()) 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 open switch modal for completion mode apps', async () => { const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION } render() fireEvent.click(getOperationsTrigger()) 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 dropdown menu is opened', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) }) }) describe('Workflow Export with Environment Variables', () => { it('should use the workflow export command for workflow apps', async () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) await waitFor(() => { expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({ appId: workflowApp.id, appName: workflowApp.name, }) }) expect(mockAppDslExport.exportAppDsl).not.toHaveBeenCalled() }) it('should show DSL export modal when workflow has secret variables', async () => { mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValueOnce({ status: 'confirmation-required', secretEnvList: [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }], }) const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) await waitFor(() => { expect(screen.getByTestId('dsl-export-modal')).toBeInTheDocument() }) }) it('should not open a modal when the workflow command downloads directly', async () => { const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) await waitFor(() => { expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({ appId: workflowApp.id, appName: workflowApp.name, }) }) expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument() }) it('should use the workflow export command for advanced chat apps', async () => { const advancedChatApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT } render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.export')) }) await waitFor(() => { expect(mockWorkflowAppDslExport.exportWorkflowAppDsl).toHaveBeenCalledWith({ appId: advancedChatApp.id, appName: advancedChatApp.name, }) }) }) it('should close DSL export modal when onClose is called', async () => { mockWorkflowAppDslExport.exportWorkflowAppDsl.mockResolvedValueOnce({ status: 'confirmation-required', secretEnvList: [{ value_type: 'secret', name: 'API_KEY', value: 'secret' }], }) const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } render() fireEvent.click(getOperationsTrigger()) 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() }) }) }) describe('Edit mutation', () => { it('should handle edit failure', async () => { mockUpdateAppMutation.mockRejectedValueOnce(new Error('Edit failed')) render() fireEvent.click(getOperationsTrigger()) 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(mockUpdateAppMutation).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Edit failed'), }) }) }) it('should fall back to the default edit failure message', async () => { mockUpdateAppMutation.mockRejectedValueOnce({ message: '' }) render() fireEvent.click(getOperationsTrigger()) 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(mockUpdateAppMutation).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) }) }) }) describe('Operations behavior', () => { it('should close operations menu after selecting an item', async () => { render() fireEvent.click(getOperationsTrigger()) fireEvent.click(await screen.findByRole('menuitem', { name: 'app.editApp' })) await waitFor(() => { expect(getOperationsTrigger()).toHaveAttribute('aria-expanded', 'false') expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() }) }) it('should handle open in explore via async window', async () => { let openedUrl = '' // Configure mockOpenAsyncWindow to actually call the callback mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { openedUrl = await callback() }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) }) await waitFor(() => { expect(exploreService.fetchInstalledAppList).toHaveBeenCalledWith(mockApp.id) expect(openedUrl).toBe('/installed/installed-1') }) }) it('should show string errors from open in explore onError callback', async () => { mockOpenAsyncWindow.mockImplementationOnce( async ( _callback: () => Promise, options?: { onError?: (err: unknown) => void }, ) => { options?.onError?.('Window failed') }, ) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('app.openInExplore')) }) await waitFor(() => { expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'Window failed' }) }) }) }) describe('Access Control', () => { it('should render the tour-controlled operations menu as presentation only', async () => { render( , ) expect(await screen.findByText('app.editApp')).toBeInTheDocument() expect( screen.getByRole('menuitem', { name: 'app.editApp', hidden: true }), ).toBeInTheDocument() expect( document.querySelector( `[data-step-by-step-tour-highlight-part="${STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}"]`, ), ).toHaveAttribute( 'data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu, ) expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true') expect(screen.getByRole('menu', { hidden: true })).toHaveClass('pointer-events-none') }) }) describe('Open in Explore - No App Found', () => { it('should tell workflow users to publish before opening in explore', async () => { const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW, workflow: undefined, }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) fireEvent.click(screen.getByText('app.openInExplore')) expect(mockOpenAsyncWindow).not.toHaveBeenCalled() expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.notPublishedYet', }) }) it('should handle case when installed_apps is empty array', async () => { vi.mocked(exploreService.fetchInstalledAppList).mockResolvedValueOnce({ has_more: false, installed_apps: [], next_cursor: null, }) // Configure mockOpenAsyncWindow to call the callback and trigger error mockOpenAsyncWindow.mockImplementationOnce( async (callback: () => Promise, options?: { onError?: (err: unknown) => void }) => { try { await callback() } catch (err) { options?.onError?.(err) } }, ) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) }) await waitFor(() => { expect(exploreService.fetchInstalledAppList).toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.notPublishedYet', }) }) }) it('should handle case when API throws in callback', async () => { vi.mocked(exploreService.fetchInstalledAppList).mockRejectedValueOnce( new Error('Network error'), ) // Configure mockOpenAsyncWindow to call the callback without catching mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { return await callback() }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { const openInExploreBtn = screen.getByText('app.openInExplore') fireEvent.click(openInExploreBtn) }) await waitFor(() => { expect(exploreService.fetchInstalledAppList).toHaveBeenCalled() }) }) }) 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() fireEvent.click(getOperationsTrigger()) 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() expect(screen.getByRole('link', { name: 'Test App' })).toBeInTheDocument() }) }) describe('WebApp Auth Enabled', () => { beforeEach(() => { mockWebappAuthEnabled = true }) it('should omit web app access control when webapp_auth is enabled', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.editApp')).toBeInTheDocument() }) expect(screen.queryByText('app.accessControl')).not.toBeInTheDocument() }) it('should omit web app access control for release-and-version permission', async () => { const appWithReleasePermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.ReleaseAndVersion, AppACLPermission.Delete], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) expect(screen.queryByText('app.accessControl')).not.toBeInTheDocument() }) it('should show resource access option when user only has app access config permission', async () => { const appWithAccessConfigPermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.Delete], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) expect(screen.queryByText('app.accessControl')).not.toBeInTheDocument() expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument() }) it('should hide resource access option when RBAC is disabled', async () => { mockRbacEnabled = false const appWithAccessConfigPermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.Delete], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument() }) it('should navigate to app access config when resource access is clicked', async () => { const appWithAccessConfigPermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', permission_keys: [AppACLPermission.AccessConfig], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { fireEvent.click(screen.getByText('common.settings.resourceAccess')) }) expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/access-config') }) it('should show open in explore when userCanAccessApp is true', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) }) it('should keep open in explore visible for unpublished workflow apps while access check is pending', async () => { mockUserCanAccessApp.result = false mockUserCanAccessApp.isLoading = true const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW, workflow: undefined, }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { expect(screen.getByText('app.openInExplore')).toBeInTheDocument() }) fireEvent.click(screen.getByText('app.openInExplore')) expect(mockOpenAsyncWindow).not.toHaveBeenCalled() expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled() expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.notPublishedYet', }) }) }) })