dify/web/app/components/apps/__tests__/app-card.spec.tsx

1752 lines
58 KiB
TypeScript

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<unknown> =>
Promise.resolve({
id: 'new-app-id',
mode: 'chat',
maintainer: 'user-1',
permission_keys: [],
}),
),
)
const mockUpdateAppMutation = vi.hoisted(() =>
vi.fn((_variables: unknown): Promise<unknown> => Promise.resolve()),
)
const mockDeleteAppMutation = vi.hoisted(() =>
vi.fn((_variables: unknown): Promise<unknown> => Promise.resolve()),
)
const mockStarAppMutation = vi.hoisted(() =>
vi.fn((_variables: unknown): Promise<unknown> => Promise.resolve()),
)
const mockUnstarAppMutation = vi.hoisted(() =>
vi.fn((_variables: unknown): Promise<unknown> => Promise.resolve()),
)
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
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<string, unknown>) =>
record({ message, ...options }),
)
return {
record,
api: Object.assign(api, {
success: vi.fn((message: unknown, options?: Record<string, unknown>) =>
record({ type: 'success', message, ...options }),
),
error: vi.fn((message: unknown, options?: Record<string, unknown>) =>
record({ type: 'error', message, ...options }),
),
warning: vi.fn((message: unknown, options?: Record<string, unknown>) =>
record({ type: 'warning', message, ...options }),
),
info: vi.fn((message: unknown, options?: Record<string, unknown>) =>
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: <T,>(defaultValue: T) => React.createContext(defaultValue),
useContext: () => ({
notify: toastMocks.api,
}),
useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => 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<unknown>) => {
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<string, unknown>) => 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<string, unknown>) => 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> = {}): 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(<AppCard app={previewOnlyApp} />)
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(<StarredAppCard app={previewOnlyApp} />)
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(<AppCard app={mockApp} />)
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should display app description', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByText('Test app description')).toBeInTheDocument()
})
it('should display author name', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByText('Test Author')).toBeInTheDocument()
})
it('should render app icon', () => {
// AppIcon component renders the emoji icon from app data
const { container } = render(<AppCard app={mockApp} />)
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(<AppCard app={imageApp} />)
expect(container.querySelector('img')).toHaveAttribute('alt', '')
})
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.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(<AppCard app={appWithTags} />)
// 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(<AppCard app={firstApp} />)
expect(screen.getByText('Old Tag')).toBeInTheDocument()
rerender(<AppCard app={refreshedApp} />)
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(<AppCard app={editableApp} />)
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(<AppCard app={tagManageApp} />)
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(<AppCard app={readonlyApp} />)
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(<AppCard app={mockApp} />)
expect(
screen.queryByRole('img', { name: 'app.accessItemsDescription.anyone' }),
).not.toBeInTheDocument()
})
})
describe('Card Interaction', () => {
it('should render card navigation as a link', () => {
render(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={starredApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={appWithoutImportExportPermission} />)
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(<StarredAppCard app={appWithoutImportExportPermission} />)
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(<AppCard app={mockApp} />)
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.export')).toBeInTheDocument()
})
})
it('should show delete option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={editableChatApp} />)
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(<AppCard app={completionApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
// 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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={chatApp} />)
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(<AppCard app={completionApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={advancedChatApp} />)
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(<AppCard app={workflowApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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<string>) => {
openedUrl = await callback()
})
render(<AppCard app={mockApp} />)
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<string>,
options?: { onError?: (err: unknown) => void },
) => {
options?.onError?.('Window failed')
},
)
render(<AppCard app={mockApp} />)
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(
<AppCard
app={mockApp}
stepByStepTourActionMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
}
stepByStepTourActionMenuOpen
/>,
)
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(<AppCard app={workflowApp} />)
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<string>, options?: { onError?: (err: unknown) => void }) => {
try {
await callback()
} catch (err) {
options?.onError?.(err)
}
},
)
render(<AppCard app={mockApp} />)
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<string>) => {
return await callback()
})
render(<AppCard app={mockApp} />)
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(<AppCard app={draftTriggerApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={appWithReleasePermission} />)
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(<AppCard app={appWithAccessConfigPermission} />)
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(<AppCard app={appWithAccessConfigPermission} />)
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(<AppCard app={appWithAccessConfigPermission} />)
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(<AppCard app={mockApp} />)
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(<AppCard app={workflowApp} />)
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',
})
})
})
})