test(web): remove Base UI component mocks (#39877)

This commit is contained in:
yyh 2026-08-02 17:51:45 +08:00 committed by GitHub
parent 02a9c51a3e
commit 802fe70423
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
130 changed files with 1210 additions and 11273 deletions

View File

@ -44,44 +44,6 @@
"count": 3
}
},
"web/__mocks__/base-ui-dropdown-menu.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
},
"jsx_a11y/interactive-supports-focus": {
"count": 2
},
"jsx_a11y/role-has-required-aria-props": {
"count": 1
}
},
"web/__mocks__/base-ui-popover.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/__mocks__/base-ui-select.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/interactive-supports-focus": {
"count": 1
},
"jsx_a11y/role-has-required-aria-props": {
"count": 2
}
},
"web/__mocks__/base-ui-tooltip.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/__mocks__/zustand.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
@ -568,11 +530,6 @@
"count": 1
}
},
"web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx": {
"jsx_a11y/role-has-required-aria-props": {
"count": 1
}
},
"web/app/components/app/configuration/debug/__tests__/index.spec.tsx": {
"typescript/no-explicit-any": {
"count": 1
@ -3420,14 +3377,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -3762,14 +3711,6 @@
"count": 1
}
},
"web/app/components/tools/labels/__tests__/selector.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/tools/labels/filter.tsx": {
"no-restricted-imports": {
"count": 1
@ -4024,14 +3965,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/__tests__/agent-strategy-selector.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -5244,11 +5177,6 @@
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/toolbar/__tests__/operator.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1

View File

@ -1,122 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '../base-ui-popover'
type PopoverHarnessProps = {
useRenderElement?: boolean
preventDefaultOnTrigger?: boolean
}
const PopoverHarness = ({
useRenderElement = false,
preventDefaultOnTrigger = false,
}: PopoverHarnessProps) => {
const [open, setOpen] = React.useState(false)
return (
<div>
<div data-testid="outside-area">outside</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
useRenderElement ? (
<button
type="button"
data-testid="custom-trigger"
onClick={(event) => {
if (preventDefaultOnTrigger) event.preventDefault()
}}
>
toggle
</button>
) : undefined
}
>
fallback trigger
</PopoverTrigger>
<PopoverContent
className="custom-content"
placement="bottom-start"
sideOffset={4}
alignOffset={8}
positionerProps={
{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>
}
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
>
<div>popover body</div>
</PopoverContent>
</Popover>
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
</div>
)
}
describe('base-ui-popover mock', () => {
it('should toggle popover content from the fallback trigger and expose content props', () => {
render(<PopoverHarness />)
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
})
it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
render(<PopoverHarness useRenderElement />)
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.mouseDown(screen.getByTestId('popover-content'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
fireEvent.mouseDown(screen.getByTestId('outside-area'))
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
})
it('should preserve rendered trigger props and respect preventDefault', () => {
render(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should keep the popover closed when the fallback trigger click is prevented', () => {
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
}
render(
<div>
<Popover open={false} onOpenChange={vi.fn()}>
<PopoverTrigger onClick={handleClick}>fallback trigger</PopoverTrigger>
<PopoverContent>
<div>popover body</div>
</PopoverContent>
</Popover>
</div>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})

View File

@ -1,230 +0,0 @@
import type { ReactNode } from 'react'
import * as React from 'react'
const DropdownMenuContext = React.createContext({
open: false,
onOpenChange: (_open: boolean) => {},
})
type DropdownMenuProps = {
children?: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerHtmlProps = React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-disabled'?: string
'data-popup-open'?: string
}
type DropdownMenuTriggerProps = TriggerHtmlProps & {
children?: ReactNode
disabled?: boolean
nativeButton?: boolean
render?:
| React.ReactElement
| ((props: TriggerHtmlProps, state: { open: boolean }) => React.ReactElement)
}
type DropdownMenuContentProps = React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
placement?: string
sideOffset?: number
alignOffset?: number
popupClassName?: string
}
export const DropdownMenu = ({ children, open, onOpenChange }: DropdownMenuProps) => {
const [localOpen, setLocalOpen] = React.useState(false)
const resolvedOpen = open ?? localOpen
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setLocalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange],
)
return (
<DropdownMenuContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}>
<div data-testid="dropdown-menu" data-open={String(resolvedOpen)}>
{children}
</div>
</DropdownMenuContext.Provider>
)
}
export const DropdownMenuTrigger = ({
children,
render,
nativeButton: _nativeButton,
onClick,
disabled,
...props
}: DropdownMenuTriggerProps) => {
const { open, onOpenChange } = React.useContext(DropdownMenuContext)
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
if (disabled) return
onClick?.(event)
if (!event.defaultPrevented) onOpenChange(!open)
}
if (typeof render === 'function') {
return render(
{
...props,
'aria-disabled': disabled || undefined,
'data-testid': props['data-testid'] ?? 'dropdown-menu-trigger',
'data-disabled': disabled ? '' : undefined,
'data-popup-open': open ? '' : undefined,
onClick: handleClick,
},
{ open },
)
}
const node = render ?? children
const isNativeButton = React.isValidElement(node) && node.type === 'button'
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
}
const triggerProps = props as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
const role =
childProps.role ??
triggerProps.role ??
(!isNativeButton && (childProps['aria-label'] || triggerProps['aria-label'])
? 'button'
: undefined)
return React.cloneElement(
triggerElement,
{
...props,
...childProps,
'data-testid':
childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'dropdown-menu-trigger',
'data-disabled': disabled ? '' : undefined,
'data-popup-open': open ? '' : undefined,
disabled: isNativeButton ? disabled : undefined,
'aria-disabled': !isNativeButton && disabled ? true : childProps['aria-disabled'],
role,
tabIndex:
childProps.tabIndex ?? triggerProps.tabIndex ?? (role === 'button' ? 0 : undefined),
onClick: (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
handleClick(event)
},
},
render ? (children ?? childProps.children) : childProps.children,
)
}
return (
<div
data-testid="dropdown-menu-trigger"
role="button"
tabIndex={0}
onClick={handleClick}
{...props}
>
{node}
</div>
)
}
export const DropdownMenuContent = ({
children,
className,
popupClassName,
placement,
sideOffset,
alignOffset,
...props
}: DropdownMenuContentProps) => {
const { open } = React.useContext(DropdownMenuContext)
if (!open) return null
return (
<div
data-testid="dropdown-menu-content"
data-placement={placement}
data-side-offset={sideOffset}
data-align-offset={alignOffset}
className={className || popupClassName}
{...props}
>
{children}
</div>
)
}
export const DropdownMenuItem = ({
children,
onClick,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => (
<div role="menuitem" onClick={onClick} {...props}>
{children}
</div>
)
export const DropdownMenuRadioGroup = ({
children,
onValueChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
value?: unknown
onValueChange?: (value: unknown) => void
}) => (
<div role="radiogroup" {...props} data-on-value-change={onValueChange ? 'true' : undefined}>
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
return React.cloneElement(
child as React.ReactElement<{ __onValueChange?: (value: unknown) => void }>,
{ __onValueChange: onValueChange },
)
})}
</div>
)
export const DropdownMenuRadioItem = ({
children,
value,
onClick,
__onValueChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
value?: unknown
__onValueChange?: (value: unknown) => void
}) => (
<div
role="radio"
onClick={(event) => {
onClick?.(event)
__onValueChange?.(value)
}}
{...props}
>
{children}
</div>
)
export const DropdownMenuRadioItemIndicator = ({ children }: { children?: ReactNode }) => (
<>{children}</>
)
export const DropdownMenuCheckboxItem = DropdownMenuItem
export const DropdownMenuCheckboxItemIndicator = ({ children }: { children?: ReactNode }) => (
<>{children}</>
)
export const DropdownMenuLabel = ({ children }: { children?: ReactNode }) => <>{children}</>
export const DropdownMenuSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div role="separator" {...props} />
)
export const DropdownMenuSub = ({ children }: { children?: ReactNode }) => <>{children}</>
export const DropdownMenuSubTrigger = DropdownMenuItem
export const DropdownMenuSubContent = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -1,190 +0,0 @@
import type { ReactNode } from 'react'
import * as React from 'react'
const PopoverContext = React.createContext({
open: false,
onOpenChange: (_open: boolean) => {},
})
type PopoverProps = {
children?: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
type TriggerHtmlProps = React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-popover-trigger'?: string
'data-popup-open'?: string
}
type PopoverTriggerProps = TriggerHtmlProps & {
children?: ReactNode
nativeButton?: boolean
render?:
| React.ReactElement
| ((props: TriggerHtmlProps, state: { open: boolean }) => React.ReactElement)
}
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode
placement?: string
sideOffset?: number
alignOffset?: number
popupClassName?: string
positionerProps?: React.HTMLAttributes<HTMLDivElement>
popupProps?: React.HTMLAttributes<HTMLDivElement>
}
export const Popover = ({ children, open, onOpenChange }: PopoverProps) => {
const [localOpen, setLocalOpen] = React.useState(false)
const resolvedOpen = open ?? localOpen
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setLocalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange],
)
React.useEffect(() => {
if (!resolvedOpen) return
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as Element | null
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) return
handleOpenChange(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleOpenChange(false)
}
document.addEventListener('mousedown', handleMouseDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [resolvedOpen, handleOpenChange])
return (
<PopoverContext.Provider
value={{
open: resolvedOpen,
onOpenChange: handleOpenChange,
}}
>
<div data-testid="popover" data-open={String(resolvedOpen)}>
{children}
</div>
</PopoverContext.Provider>
)
}
export const PopoverTrigger = ({
children,
render,
nativeButton: _nativeButton,
onClick,
...props
}: PopoverTriggerProps) => {
const { open, onOpenChange } = React.useContext(PopoverContext)
if (typeof render === 'function') {
const triggerProps: TriggerHtmlProps = {
...props,
'data-testid': props['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'data-popup-open': open ? '' : undefined,
onClick: (event: React.MouseEvent<HTMLElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
onOpenChange(!open)
},
}
return render(triggerProps, { open })
}
const node = render ?? children
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
}
const triggerProps = props as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
return React.cloneElement(
triggerElement,
{
...props,
...childProps,
'data-testid':
childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'data-popup-open': open ? '' : undefined,
onClick: (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
onClick?.(event)
if (event.defaultPrevented) return
onOpenChange(!open)
},
},
render ? (children ?? childProps.children) : childProps.children,
)
}
return (
<div
data-testid="popover-trigger"
data-popover-trigger="true"
data-popup-open={open ? '' : undefined}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented) return
onOpenChange(!open)
}}
{...props}
>
{node}
</div>
)
}
export const PopoverContent = ({
children,
className,
placement,
sideOffset,
alignOffset,
popupClassName,
positionerProps,
popupProps,
...props
}: PopoverContentProps) => {
const { open } = React.useContext(PopoverContext)
if (!open) return null
return (
<div
data-testid="popover-content"
data-popover-content="true"
data-placement={placement}
data-side-offset={sideOffset}
data-align-offset={alignOffset}
className={className || popupClassName}
{...positionerProps}
{...popupProps}
{...props}
>
{children}
</div>
)
}
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -1,72 +0,0 @@
import type { ReactNode } from 'react'
import * as React from 'react'
const SelectContext = React.createContext({
value: undefined as unknown,
onValueChange: (_value: unknown) => {},
})
type SelectProps = {
children?: ReactNode
value?: unknown
onValueChange?: (value: unknown) => void
}
export const Select = ({ children, value, onValueChange }: SelectProps) => (
<SelectContext.Provider value={{ value, onValueChange: onValueChange ?? (() => {}) }}>
<div data-testid="select-root">{children}</div>
</SelectContext.Provider>
)
export const SelectTrigger = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button type="button" role="combobox" {...props}>
{children}
</button>
)
export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder}</>
export const SelectContent = ({
children,
popupClassName,
}: {
children?: ReactNode
popupClassName?: string
}) => (
<div data-side="bottom" data-testid="select-content" className={popupClassName}>
{children}
</div>
)
export const SelectItem = ({
children,
value,
onClick,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode; value?: unknown }) => {
const select = React.useContext(SelectContext)
return (
<div
role="option"
onClick={(event) => {
onClick?.(event)
select.onValueChange(value)
}}
{...props}
>
{children}
</div>
)
}
export const SelectItemText = ({ children }: { children?: ReactNode }) => <>{children}</>
export const SelectItemIndicator = ({ children }: { children?: ReactNode }) => <>{children}</>
export const SelectGroup = ({ children }: { children?: ReactNode }) => <>{children}</>
export const SelectLabel = () => null
export const SelectGroupLabel = ({ children }: { children?: ReactNode }) => <>{children}</>
export const SelectSeparator = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div role="separator" {...props} />
)

View File

@ -1,101 +0,0 @@
import type { ReactNode } from 'react'
import * as React from 'react'
const TooltipContext = React.createContext({
open: false,
onOpenChange: (_open: boolean) => {},
})
type TooltipProps = {
children?: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
export const Tooltip = ({ children, open, onOpenChange }: TooltipProps) => {
const [localOpen, setLocalOpen] = React.useState(false)
const resolvedOpen = open ?? localOpen
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setLocalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange],
)
return (
<TooltipContext.Provider value={{ open: resolvedOpen, onOpenChange: handleOpenChange }}>
{children}
</TooltipContext.Provider>
)
}
export const TooltipTrigger = ({
children,
render,
nativeButton: _nativeButton,
...props
}: React.HTMLAttributes<HTMLElement> & {
children?: ReactNode
render?: React.ReactElement
nativeButton?: boolean
}) => {
const { open, onOpenChange } = React.useContext(TooltipContext)
const node = render ?? children
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement>
return React.cloneElement(triggerElement, {
...props,
...childProps,
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseEnter?.(event)
props.onMouseEnter?.(event)
onOpenChange(true)
},
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
childProps.onMouseLeave?.(event)
props.onMouseLeave?.(event)
onOpenChange(false)
},
onClick: (event: React.MouseEvent<HTMLElement>) => {
childProps.onClick?.(event)
props.onClick?.(event)
onOpenChange(!open)
},
})
}
return (
<span
{...props}
onMouseEnter={(event) => {
props.onMouseEnter?.(event)
onOpenChange(true)
}}
onMouseLeave={(event) => {
props.onMouseLeave?.(event)
onOpenChange(false)
}}
onClick={(event) => {
props.onClick?.(event)
onOpenChange(!open)
}}
>
{node}
</span>
)
}
export const TooltipContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => {
const { open } = React.useContext(TooltipContext)
if (!open) return null
return <div {...props}>{children}</div>
}
export const TooltipProvider = ({ children }: { children?: ReactNode }) => <>{children}</>

View File

@ -0,0 +1,37 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
const testFilePattern = /\.(?:spec|test)\.[cm]?[jt]sx?$/
const interactivePrimitiveMockPattern =
/vi\.mock\(\s*['"]@langgenius\/dify-ui\/(?:alert-dialog|avatar|button|dialog|dropdown-menu|pagination|popover|select|slider|switch|textarea|tooltip)['"]/
const interactiveWrapperMockPattern =
/vi\.mock\(\s*['"][^'"]*(?:block-selector|plugin-version-picker|time-picker)['"]/
const collectTestFiles = (directory: string): string[] => {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) return collectTestFiles(path)
return testFilePattern.test(entry.name) ? [path] : []
})
}
describe('interactive component mock boundary', () => {
it('keeps Dify UI primitives and feature-owned interactive wrappers real', () => {
const webRoot = process.cwd()
const testFiles = ['__tests__', 'app', 'features'].flatMap((directory) =>
collectTestFiles(resolve(webRoot, directory)),
)
const violations = testFiles.flatMap((file) => {
const source = readFileSync(file, 'utf8')
if (
!interactivePrimitiveMockPattern.test(source) &&
!interactiveWrapperMockPattern.test(source)
) {
return []
}
return [relative(webRoot, file)]
})
expect(violations).toEqual([])
})
})

View File

@ -58,32 +58,6 @@ vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view',
default: ({ appId }: { appId: string }) => <div data-testid="card-view" data-app-id={appId} />,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
className,
size,
variant,
}: {
children: React.ReactNode
onClick?: () => void
className?: string
size?: string
variant?: string
}) => (
<button
type="button"
onClick={onClick}
className={className}
data-size={size}
data-variant={variant}
>
{children}
</button>
),
}))
vi.mock('../app-operations', () => ({
default: ({
primaryOperations,

View File

@ -14,8 +14,6 @@ const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockWorkspacePermissionKeys: string[] = ['snippets.create_and_modify', 'snippets.management']
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
const mockConsoleState = vi.hoisted(() => ({
current: {
get workspacePermissionKeys() {
@ -46,51 +44,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
mockDropdownOpen ? <div>{children}</div> : null,
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
@ -173,8 +126,6 @@ describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkspacePermissionKeys = ['snippets.create_and_modify', 'snippets.management']
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.

View File

@ -171,8 +171,6 @@ vi.mock('@/app/components/tools/workflow-tool', () => ({
),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('../sections', () => ({
PublisherSummarySection: (props: Record<string, any>) => {
sectionProps.summary = props

View File

@ -17,91 +17,6 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext<{
open: boolean
setOpen: (nextOpen: boolean) => void
} | null>(null)
const useOpenContext = () => {
const context = ReactModule.use(OpenContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<OpenContext.Provider value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="portal-root">{children}</div>
</OpenContext.Provider>
),
DropdownMenuTrigger: ({
children,
render,
}: {
children: React.ReactNode
render?: React.ReactElement
}) => {
const { open, setOpen } = useOpenContext()
if (render) {
return ReactModule.cloneElement(
render,
{
onClick: () => setOpen(!open),
} as Record<string, unknown>,
children,
)
}
return (
<button type="button" onClick={() => setOpen(!open)}>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => {
const context = useOpenContext()
return context.open ? <div className={popupClassName}>{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => {
const { setOpen } = useOpenContext()
return (
<button
type="button"
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('PublishWithMultipleModel', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -3,13 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import EditModal from '../edit-modal'
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Conversation history edit modal', () => {
const data: ConversationHistoriesRole = {
user_prefix: 'user',

View File

@ -1,6 +1,7 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { withSelectorKey } from '@/test/i18n-mock'
import ConfigModalFormFields from '../form-fields'
@ -84,46 +85,6 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', (
),
}))
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: ReactNode
}) => (
<div>
<button
type="button"
onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}
>{`ui-select:${value}`}</button>
<button type="button" onClick={() => onValueChange('__empty__')}>
ui-select-empty
</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../field', () => ({
default: ({ children, title }: { children: ReactNode; title: string }) => (
<div>
@ -201,7 +162,8 @@ const createBaseProps = () => {
}
describe('ConfigModalFormFields', () => {
it('should update paragraph, number, checkbox, and select defaults', () => {
it('should update paragraph, number, checkbox, and select defaults', async () => {
const user = userEvent.setup()
const paragraphProps = createBaseProps()
paragraphProps.tempPayload = {
...paragraphProps.tempPayload,
@ -229,9 +191,12 @@ describe('ConfigModalFormFields', () => {
default: false,
}
checkboxProps.checkboxDefaultSelectValue = 'true'
render(<ConfigModalFormFields {...checkboxProps} />)
fireEvent.click(screen.getByText('ui-select:true'))
const checkboxView = render(<ConfigModalFormFields {...checkboxProps} />)
await user.click(screen.getByRole('combobox'))
const checkboxOptions = await screen.findAllByRole('option')
await user.click(checkboxOptions[1]!)
expect(checkboxProps.payloadChangeHandlers.default).toHaveBeenCalledWith(false)
checkboxView.unmount()
const selectProps = createBaseProps()
selectProps.tempPayload = {
@ -242,7 +207,9 @@ describe('ConfigModalFormFields', () => {
selectProps.options = ['alpha', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
fireEvent.click(screen.getByText('config-select'))
fireEvent.click(screen.getByText('ui-select:alpha'))
await user.click(screen.getByRole('combobox'))
const selectOptions = await screen.findAllByRole('option')
await user.click(selectOptions[2]!)
expect(selectProps.payloadChangeHandlers.options).toHaveBeenCalledWith(['alpha', 'beta'])
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
})
@ -348,7 +315,8 @@ describe('ConfigModalFormFields', () => {
expect(textProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
})
it('should clear select defaults and apply uploader fallback values', () => {
it('should clear select defaults and apply uploader fallback values', async () => {
const user = userEvent.setup()
const selectProps = createBaseProps()
selectProps.tempPayload = {
...selectProps.tempPayload,
@ -356,10 +324,13 @@ describe('ConfigModalFormFields', () => {
default: 'alpha',
}
selectProps.options = ['alpha', ' ', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
const selectView = render(<ConfigModalFormFields {...selectProps} />)
fireEvent.click(screen.getByText('ui-select-empty'))
await user.click(screen.getByRole('combobox'))
const selectOptions = await screen.findAllByRole('option')
await user.click(selectOptions[0]!)
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith(undefined)
selectView.unmount()
const singleFallbackProps = createBaseProps()
singleFallbackProps.tempPayload = {
@ -416,21 +387,10 @@ describe('ConfigModalFormFields', () => {
render(<ConfigModalFormFields {...selectWithoutOptionsProps} />)
expect(screen.getAllByText('config-select')).toHaveLength(1)
expect(screen.queryByText('ui-select:__empty__')).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('should preserve existing select and file defaults when present', () => {
const selectProps = createBaseProps()
selectProps.tempPayload = {
...selectProps.tempPayload,
type: InputVarType.select,
default: undefined,
}
selectProps.options = ['alpha', 'beta']
render(<ConfigModalFormFields {...selectProps} />)
expect(screen.getByText('ui-select:__empty__')).toBeInTheDocument()
it('should preserve existing file defaults when present', () => {
const existingFile = {
fileId: 'existing-file',
type: 'local_file',

View File

@ -3,8 +3,6 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TypeSelector from '../type-select'
vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select'))
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
default: ({ type }: { type: string }) => <span>{type}</span>,
}))

View File

@ -3,25 +3,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { MAX_ITERATIONS_NUM } from '@/config'
import { AgentSetting } from '../index'
vi.mock('@langgenius/dify-ui/slider', () => ({
Slider: (props: {
className?: string
min?: number
max?: number
value: number
onValueChange: (value: number) => void
}) => (
<input
type="range"
className={`slider ${props.className ?? ''}`}
min={props.min}
max={props.max}
value={props.value}
onChange={(e) => props.onValueChange(Number(e.target.value))}
/>
),
}))
const basePayload = {
enabled: true,
strategy: 'react',

View File

@ -1,45 +1,8 @@
import type { ReactNode } from 'react'
import type { Props } from '../var-picker'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import VarPicker from '../var-picker'
vi.mock('@langgenius/dify-ui/popover', () => {
const PopoverContext = React.createContext({
open: false,
onOpenChange: undefined as ((open: boolean) => void) | undefined,
})
return {
Popover: ({
children,
open,
onOpenChange,
}: {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => <PopoverContext value={{ open: !!open, onOpenChange }}>{children}</PopoverContext>,
PopoverTrigger: ({ render }: { render?: ReactNode }) => {
const { open, onOpenChange } = React.use(PopoverContext)
return (
<button type="button" aria-label="choose variable" onClick={() => onOpenChange?.(!open)}>
{render}
</button>
)
},
PopoverContent: ({ children }: { children: ReactNode }) => {
const { open } = React.use(PopoverContext)
return open ? (
<div role="dialog" aria-label="variable options">
{children}
</div>
) : null
},
}
})
const options: Props['options'] = [
{ name: 'Variable 1', value: 'var1', type: 'string' },
{ name: 'Variable 2', value: 'var2', type: 'number' },
@ -48,7 +11,7 @@ const options: Props['options'] = [
describe('VarPicker', () => {
it('shows the selected variable', () => {
render(<VarPicker value="var1" options={options} onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: 'choose variable' })).toHaveTextContent('var1')
expect(screen.getByRole('button')).toHaveTextContent('var1')
})
it('shows the configured empty-selection message', () => {
@ -64,19 +27,21 @@ describe('VarPicker', () => {
})
it('selects a variable and closes the options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<VarPicker value="var1" options={options} onChange={onChange} />)
await userEvent.click(screen.getByRole('button', { name: 'choose variable' }))
await userEvent.click(screen.getByText('var2'))
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('var2'))
expect(onChange).toHaveBeenCalledWith('var2')
expect(screen.queryByRole('dialog', { name: 'variable options' })).not.toBeInTheDocument()
expect(screen.queryByText('var2')).not.toBeInTheDocument()
})
it('shows the empty state when no variables are available', async () => {
const user = userEvent.setup()
render(<VarPicker value={undefined} options={[]} onChange={vi.fn()} />)
await userEvent.click(screen.getByRole('button', { name: 'choose variable' }))
await user.click(screen.getByRole('button'))
expect(screen.getByText('appDebug.feature.dataSet.queryVariable.noVar')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,7 @@
import type { Inputs, ModelConfig } from '@/models/debug'
import type { PromptVariable } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ChatUserInput from '../chat-user-input'
const mockSetInputs = vi.fn()
@ -41,75 +42,6 @@ vi.mock('@/app/components/base/input', () => ({
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
value?: string | null
}>({})
return {
Select: ({
children,
disabled,
onValueChange,
value,
}: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
value?: string | null
}) => (
<SelectContext.Provider value={{ disabled, onValueChange, value }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => {
const context = React.use(SelectContext)
return <>{context.value || placeholder}</>
},
SelectTrigger: ({ children, className }: { children: React.ReactNode; className?: string }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button
data-testid="select-input"
type="button"
disabled={context.disabled}
className={className}
>
{children}
</button>
<button
data-testid="select-empty"
type="button"
onClick={() => context.onValueChange?.('')}
>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
return (
<button
data-testid={`select-${value}`}
type="button"
role="option"
onClick={() => context.onValueChange?.(value)}
>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({
name,
@ -256,7 +188,8 @@ describe('ChatUserInput', () => {
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
})
it('should render select input type', () => {
it('should render select input type', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue(
createContextValue({
modelConfig: createModelConfig([
@ -271,10 +204,11 @@ describe('ChatUserInput', () => {
)
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('select-input')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByText('C')).toBeInTheDocument()
const select = screen.getByRole('combobox')
await user.click(select)
expect(await screen.findByRole('option', { name: 'A' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'B' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'C' })).toBeInTheDocument()
})
it('should render number input type', () => {
@ -324,7 +258,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('select-input')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('should show optional label for non-required fields', () => {
@ -497,7 +431,8 @@ describe('ChatUserInput', () => {
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
})
it('should call setInputs when select input changes', () => {
it('should call setInputs when select input changes', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue(
createContextValue({
modelConfig: createModelConfig([
@ -512,31 +447,12 @@ describe('ChatUserInput', () => {
)
render(<ChatUserInput inputs={{ choice: 'A' }} />)
fireEvent.click(screen.getByTestId('select-B'))
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'B' }))
expect(mockSetInputs).toHaveBeenCalledWith({ choice: 'B' })
})
it('should ignore empty select updates', () => {
mockUseContext.mockReturnValue(
createContextValue({
modelConfig: createModelConfig([
createPromptVariable({
key: 'choice',
name: 'Choice',
type: 'select',
options: ['A', 'B', 'C'],
}),
]),
}),
)
render(<ChatUserInput inputs={{}} />)
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should call setInputs when number input changes', () => {
mockUseContext.mockReturnValue(
createContextValue({
@ -676,7 +592,7 @@ describe('ChatUserInput', () => {
)
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('select-input')).toBeDisabled()
expect(screen.getByRole('combobox')).toBeDisabled()
})
it('should disable checkbox when configuration is readonly and test/run is denied', () => {
@ -862,7 +778,8 @@ describe('ChatUserInput', () => {
})
describe('Edge Cases', () => {
it('should handle select with empty options', () => {
it('should show no options when a select has no configured choices', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue(
createContextValue({
modelConfig: createModelConfig([
@ -872,25 +789,11 @@ describe('ChatUserInput', () => {
)
render(<ChatUserInput inputs={{}} />)
const select = screen.getByTestId('select-input')
expect(select).toBeInTheDocument()
const select = screen.getByRole('combobox')
await user.click(select)
expect(screen.queryAllByRole('option')).toHaveLength(0)
})
it('should handle select with undefined options', () => {
mockUseContext.mockReturnValue(
createContextValue({
modelConfig: createModelConfig([
createPromptVariable({ key: 'choice', name: 'Choice', type: 'select' }),
]),
}),
)
render(<ChatUserInput inputs={{}} />)
const select = screen.getByTestId('select-input')
expect(select).toBeInTheDocument()
})
it('should preserve other input values when updating one field', () => {
mockUseContext.mockReturnValue(
createContextValue({

View File

@ -102,10 +102,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
},
}))
vi.mock('@langgenius/dify-ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
const createModelAndParameter = (
overrides: Partial<ModelAndParameter> = {},
): ModelAndParameter => ({

View File

@ -1,6 +1,7 @@
/* oxlint-disable typescript/no-explicit-any */
import type { IPromptValuePanelProps } from '../index'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ConfigContext from '@/context/debug-configuration'
@ -9,29 +10,6 @@ import PromptValuePanel from '../index'
const mockSetShowAppConfigureFeaturesModal = vi.fn()
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button
type="button"
data-disabled={disabled ? 'true' : 'false'}
className={className}
onClick={() => onClick?.()}
>
{children}
</button>
),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (
selector: (state: {
@ -65,60 +43,6 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
value?: string | null
}>({})
return {
Select: ({
children,
onValueChange,
value,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
value?: string | null
}) => (
<SelectContext.Provider value={{ onValueChange, value }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => {
const context = React.use(SelectContext)
return <>{context.value || placeholder}</>
},
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button
data-testid="select-empty"
type="button"
onClick={() => context.onValueChange?.('')}
>
empty select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ name, onChange }: { name: string; onChange: (value: boolean) => void }) => (
<button type="button" data-testid={`bool-input-${name}`} onClick={() => onChange(true)}>
@ -221,7 +145,7 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).toHaveAttribute('data-disabled', 'false')
expect(runButton).toBeEnabled()
fireEvent.click(runButton)
await waitFor(() => expect(mockOnSend).toHaveBeenCalledTimes(1))
})
@ -237,22 +161,7 @@ describe('PromptValuePanel', () => {
})
const runButton = screen.getByRole('button', { name: 'appDebug.inputs.run' })
expect(runButton).toHaveAttribute('data-disabled', 'true')
})
it('invokes the tooltip-branch run handler when the click callback is triggered', () => {
renderPanel({
context: {
mode: AppModeEnum.CHAT,
},
props: {
appType: AppModeEnum.CHAT,
},
})
fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.run' }))
expect(mockOnSend).toHaveBeenCalledTimes(1)
expect(runButton).toBeDisabled()
})
it('hydrates default values, supports advanced prompt gating, and toggles the feature panel', () => {
@ -282,10 +191,7 @@ describe('PromptValuePanel', () => {
})
expect(mockSetInputs).toHaveBeenCalledWith({ textVar: 'default text' })
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
fireEvent.click(screen.getByText('feature bar'))
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalled()
@ -309,13 +215,11 @@ describe('PromptValuePanel', () => {
},
})
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
})
it('renders paragraph, select, number, checkbox, and vision inputs', () => {
it('renders paragraph, select, number, checkbox, and vision inputs', async () => {
const user = userEvent.setup()
const onVisionFilesChange = vi.fn()
renderPanel({
context: {
@ -357,7 +261,8 @@ describe('PromptValuePanel', () => {
fireEvent.change(screen.getByPlaceholderText('Paragraph Var'), {
target: { value: 'updated paragraph' },
})
fireEvent.click(screen.getByText('b'))
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'b' }))
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
fireEvent.click(screen.getByText('bool-input'))
fireEvent.click(screen.getByText('image-uploader'))
@ -378,36 +283,6 @@ describe('PromptValuePanel', () => {
])
})
it('ignores empty select values when choosing prompt options', () => {
renderPanel({
context: {
modelConfig: {
configs: {
prompt_template: 'prompt template',
prompt_variables: [
{
key: 'selectVar',
name: 'Select Var',
type: 'select',
options: ['a', 'b'],
required: false,
},
],
},
},
},
props: {
inputs: {
selectVar: 'a',
},
},
})
fireEvent.click(screen.getByTestId('select-empty'))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('ignores updates when the rendered field is not tracked in the prompt variable lookup', () => {
const filteredPromptVariables = {
length: 1,
@ -508,14 +383,8 @@ describe('PromptValuePanel', () => {
},
})
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute(
'data-disabled',
'false',
)
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute(
'data-disabled',
'false',
)
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'feature bar' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'feature bar' })).toHaveAttribute(
'data-hide-edit-entrance',
@ -532,14 +401,8 @@ describe('PromptValuePanel', () => {
})
expect(screen.getByPlaceholderText('Text Var')).toHaveAttribute('readonly')
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
})
it('marks debug inputs and actions as disabled when configuration is readonly and test/run permission is missing', () => {
@ -551,14 +414,8 @@ describe('PromptValuePanel', () => {
})
expect(screen.getByPlaceholderText('Text Var')).toHaveAttribute('readonly')
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toHaveAttribute(
'data-disabled',
'true',
)
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'appDebug.inputs.run' })).toBeDisabled()
})
it('collapses the user input panel and hides the clear and run actions', () => {

View File

@ -87,14 +87,6 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
<div>
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
</div>
),
}))
describe('Logs', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -179,7 +171,7 @@ describe('Logs', () => {
/>,
)
fireEvent.click(screen.getByText('go-to-page-2'))
fireEvent.click(screen.getByRole('button', { name: 'Go to page 2' }))
expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false })
})

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ModelInfo from '../model-info'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
@ -30,62 +31,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-name'
),
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext<{
open: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
return {
Popover: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<PopoverContext.Provider value={{ open, onOpenChange }}>
<div data-testid="popover-root" data-open={open ? 'true' : 'false'}>
{children}
</div>
</PopoverContext.Provider>
),
PopoverTrigger: ({
children,
render,
}: {
children?: React.ReactNode
render?: React.ReactNode
}) => {
const context = React.useContext(PopoverContext)
const content = render ?? children
const handleClick = () => {
context?.onOpenChange?.(!context.open)
}
if (React.isValidElement(content)) {
const element = content as React.ReactElement<{ onClick?: () => void }>
return React.cloneElement(element, { onClick: handleClick })
}
return (
<button type="button" data-testid="popover-trigger" onClick={handleClick}>
{content}
</button>
)
},
PopoverContent: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(PopoverContext)
if (!context?.open) return null
return <div data-testid="popover-content">{children}</div>
},
}
})
describe('ModelInfo', () => {
const defaultModel = {
name: 'gpt-4',
@ -132,32 +77,32 @@ describe('ModelInfo', () => {
it('should be closed by default', () => {
render(<ModelInfo model={defaultModel} />)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
expect(screen.queryByText(/(?:^|\.)detail\.modelParams(?=$|:)/)).not.toBeInTheDocument()
})
it('should open when info button is clicked', () => {
it('should open when info button is clicked', async () => {
const user = userEvent.setup()
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByRole('button')
fireEvent.click(trigger)
await user.click(trigger)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)detail\.modelParams(?=$|:)/)).toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
it('should close when info button is clicked again', () => {
it('should close when info button is clicked again', async () => {
const user = userEvent.setup()
render(<ModelInfo model={defaultModel} />)
const trigger = screen.getByRole('button')
// Open
fireEvent.click(trigger)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'true')
await user.click(trigger)
expect(screen.getByText(/(?:^|\.)detail\.modelParams(?=$|:)/)).toBeInTheDocument()
// Close
fireEvent.click(trigger)
expect(screen.getByTestId('popover-root')).toHaveAttribute('data-open', 'false')
await user.click(trigger)
expect(screen.queryByText(/(?:^|\.)detail\.modelParams(?=$|:)/)).not.toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
})

View File

@ -48,9 +48,23 @@ vi.mock('@/service/use-triggers', () => ({
}))
vi.mock('@/utils', () => ({
asyncRunSafe: async <T,>(promise: Promise<T>) => {
try {
return [null, await promise]
} catch (error) {
return [error]
}
},
canFindTool: () => false,
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({
workspacePermissionKeys: ['app.create_and_management'],
}))
})
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ type }: { type: string }) => (
<div data-testid="block-icon" data-type={type}>
@ -59,28 +73,6 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
),
}))
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({
checked,
onCheckedChange,
disabled,
}: {
checked: boolean
onCheckedChange: (v: boolean) => void
disabled: boolean
}) => (
<button
data-testid="switch"
data-checked={checked ? 'true' : 'false'}
data-disabled={disabled ? 'true' : 'false'}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
>
Switch
</button>
),
}))
describe('TriggerCard', () => {
const mockAppInfo = {
id: 'test-app-id',
@ -210,7 +202,7 @@ describe('TriggerCard', () => {
it('should render switches for each trigger', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switches = screen.getAllByTestId('switch')
const switches = screen.getAllByRole('switch')
expect(switches.length).toBe(2)
})
})
@ -231,7 +223,7 @@ describe('TriggerCard', () => {
it('should call updateTriggerStatus when toggle is clicked', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
@ -246,7 +238,7 @@ describe('TriggerCard', () => {
it('should update trigger status in store optimistically', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
@ -257,7 +249,7 @@ describe('TriggerCard', () => {
it('should invalidate app triggers after successful update', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
@ -268,7 +260,7 @@ describe('TriggerCard', () => {
it('should call onToggleResult with null on success', async () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
@ -282,7 +274,7 @@ describe('TriggerCard', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
fireEvent.click(switchBtn)
await waitFor(() => {
@ -360,7 +352,7 @@ describe('TriggerCard', () => {
render(<TriggerCard appInfo={mockAppInfo} onToggleResult={mockOnToggleResult} />)
const switchBtn = screen.getByTestId('switch')
const switchBtn = screen.getByRole('switch')
expect(switchBtn).toBeInTheDocument()
})
@ -383,8 +375,8 @@ describe('TriggerCard', () => {
<TriggerCard appInfo={appInfoWithoutEditPermission} onToggleResult={mockOnToggleResult} />,
)
const switchBtn = screen.getByTestId('switch')
expect(switchBtn).toHaveAttribute('data-disabled', 'true')
const switchBtn = screen.getByRole('switch')
expect(switchBtn).toHaveAttribute('aria-disabled', 'true')
fireEvent.click(switchBtn)

View File

@ -41,6 +41,9 @@ const render = (ui: React.ReactElement) =>
},
})
const getOperationsTrigger = () =>
screen.getByRole('button', { name: /common\.operation\.moreActionsFor/ })
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
@ -358,123 +361,6 @@ vi.mock('@/next/dynamic', () => ({
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
type DropdownMenuContextValue = {
isOpen: boolean
setOpen: (open: boolean) => void
}
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open = false,
modal,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
modal?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-menu" data-open={open} data-modal={modal}>
{children}
</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-trigger"
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
className,
popupClassName,
popupProps,
positionerProps,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
popupProps?: React.HTMLAttributes<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen) return null
return (
<div data-testid="dropdown-menu-positioner" {...positionerProps}>
<div
data-testid="dropdown-menu-content"
role="menu"
className={[className, popupClassName].filter(Boolean).join(' ')}
{...popupProps}
>
{children}
</div>
</div>
)
},
DropdownMenuItem: ({
children,
className,
onClick,
destructive,
disabled,
}: {
children: React.ReactNode
className?: string
onClick?: React.MouseEventHandler<HTMLButtonElement>
destructive?: boolean
disabled?: boolean
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
data-testid="dropdown-menu-item"
role="menuitem"
type="button"
className={className}
data-destructive={destructive}
disabled={disabled}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-menu-separator" />,
}
})
// AppCardTags has tag API dependencies - mock for isolated testing
vi.mock('@/features/tag-management/components/app-card-tags', () => ({
AppCardTags: ({
@ -839,37 +725,24 @@ describe('AppCard', () => {
})
describe('Operations Menu', () => {
it('should render operations dropdown menu', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
})
it('should render dropdown menu as non-modal', () => {
render(<AppCard app={mockApp} />)
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
})
it('should reveal operations trigger when card receives keyboard focus', () => {
render(<AppCard app={mockApp} />)
const operationsTriggerWrapper = screen
.getByTestId('dropdown-menu-trigger')
.closest('.absolute')
const operationsTrigger = getOperationsTrigger()
const operationsTriggerWrapper = operationsTrigger.closest('.absolute')
expect(operationsTriggerWrapper).toHaveClass('top-2')
expect(operationsTriggerWrapper).toHaveClass('right-2')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
expect(operationsTriggerWrapper).not.toHaveClass('w-[120px]')
expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass('focus-visible:ring-2')
expect(screen.getByTestId('dropdown-menu-trigger')).toHaveClass(
'focus-visible:ring-state-accent-solid',
)
expect(operationsTrigger).toHaveClass('focus-visible:ring-2')
expect(operationsTrigger).toHaveClass('focus-visible:ring-state-accent-solid')
})
it('should show edit option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
@ -879,7 +752,7 @@ describe('AppCard', () => {
it('should show duplicate option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
@ -894,7 +767,7 @@ describe('AppCard', () => {
})
render(<AppCard app={appWithoutImportExportPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
@ -910,7 +783,7 @@ describe('AppCard', () => {
})
render(<StarredAppCard app={appWithoutImportExportPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
@ -921,7 +794,7 @@ describe('AppCard', () => {
it('should show export option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.export')).toBeInTheDocument()
@ -931,7 +804,7 @@ describe('AppCard', () => {
it('should show delete option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
@ -942,7 +815,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText(/switch/i)).toBeInTheDocument()
@ -959,7 +832,7 @@ describe('AppCard', () => {
})
render(<AppCard app={editableChatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText(/switch/i)).toBeInTheDocument()
@ -971,7 +844,7 @@ describe('AppCard', () => {
const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION }
render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText(/switch/i)).toBeInTheDocument()
@ -982,7 +855,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.queryByText(/switch/i)).not.toBeInTheDocument()
@ -994,7 +867,7 @@ describe('AppCard', () => {
it('should open edit modal when edit button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const editButton = screen.getByText('app.editApp')
@ -1009,7 +882,7 @@ describe('AppCard', () => {
it('should open duplicate modal when duplicate button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const duplicateButton = screen.getByText('app.duplicate')
@ -1024,7 +897,7 @@ describe('AppCard', () => {
it('should open confirm dialog when delete button is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
})
@ -1032,7 +905,7 @@ describe('AppCard', () => {
it('should close confirm dialog when cancel is clicked', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
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' }))
@ -1044,7 +917,7 @@ describe('AppCard', () => {
it('should not submit delete when confirmation text does not match', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
const form = (await screen.findByRole('alertdialog')).querySelector('form')
@ -1057,7 +930,7 @@ describe('AppCard', () => {
it('should close edit modal when onHide is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1077,7 +950,7 @@ describe('AppCard', () => {
it('should close duplicate modal when onHide is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -1097,7 +970,7 @@ describe('AppCard', () => {
it('should clear delete confirmation input after closing the dialog', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
const deleteInput = await screen.findByRole('textbox')
@ -1108,7 +981,7 @@ describe('AppCard', () => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
await waitFor(() => {
@ -1122,7 +995,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
// Open dropdown menu and click delete
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
@ -1140,7 +1013,7 @@ describe('AppCard', () => {
it('should not call onRefresh after successful delete', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
@ -1161,7 +1034,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
@ -1185,7 +1058,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
@ -1204,7 +1077,7 @@ describe('AppCard', () => {
it('should call updateAppInfo API when editing app', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1223,7 +1096,7 @@ describe('AppCard', () => {
it('should edit successfully without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1243,7 +1116,7 @@ describe('AppCard', () => {
it('should call copyApp API when duplicating app', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -1262,7 +1135,7 @@ describe('AppCard', () => {
it('should call onPlanInfoChanged after successful duplication', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -1281,7 +1154,7 @@ describe('AppCard', () => {
it('should duplicate successfully without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -1304,7 +1177,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.duplicate'))
})
@ -1327,7 +1200,7 @@ describe('AppCard', () => {
it('should export the app DSL when exporting', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1352,7 +1225,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1366,7 +1239,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1386,7 +1259,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1406,7 +1279,7 @@ describe('AppCard', () => {
const chatApp = { ...mockApp, mode: AppModeEnum.CHAT }
render(<AppCard app={chatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1426,7 +1299,7 @@ describe('AppCard', () => {
const completionApp = { ...mockApp, mode: AppModeEnum.COMPLETION }
render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1441,7 +1314,7 @@ describe('AppCard', () => {
it('should show open in explore option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
@ -1454,7 +1327,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1477,7 +1350,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1491,7 +1364,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1510,7 +1383,7 @@ describe('AppCard', () => {
const advancedChatApp = { ...mockApp, mode: AppModeEnum.ADVANCED_CHAT }
render(<AppCard app={advancedChatApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1532,7 +1405,7 @@ describe('AppCard', () => {
const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW }
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.export'))
})
@ -1623,7 +1496,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1648,7 +1521,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1668,7 +1541,7 @@ describe('AppCard', () => {
it('should close edit modal after successful edit', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.editApp'))
})
@ -1710,7 +1583,7 @@ describe('AppCard', () => {
const chatApp = createMockApp({ mode: AppModeEnum.CHAT })
render(<AppCard app={chatApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.switch'))
})
@ -1732,7 +1605,7 @@ describe('AppCard', () => {
const completionApp = createMockApp({ mode: AppModeEnum.COMPLETION })
const { unmount } = render(<AppCard app={completionApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
@ -1743,7 +1616,7 @@ describe('AppCard', () => {
const workflowApp = createMockApp({ mode: AppModeEnum.WORKFLOW })
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
})
@ -1767,15 +1640,11 @@ describe('AppCard', () => {
it('should close operations menu after selecting an item', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
await waitFor(() => {
expect(screen.getByTestId('dropdown-menu-content')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('app.editApp'))
fireEvent.click(getOperationsTrigger())
fireEvent.click(await screen.findByRole('menuitem', { name: 'app.editApp' }))
await waitFor(() => {
expect(screen.queryByTestId('dropdown-menu-content')).not.toBeInTheDocument()
expect(getOperationsTrigger()).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument()
})
})
@ -1783,7 +1652,7 @@ describe('AppCard', () => {
it('should click open in explore button', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1807,7 +1676,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1835,7 +1704,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1858,7 +1727,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.openInExplore'))
})
@ -1873,7 +1742,7 @@ describe('AppCard', () => {
it('should render operations menu correctly', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
expect(screen.getByText('app.duplicate')).toBeInTheDocument()
@ -1897,12 +1766,16 @@ describe('AppCard', () => {
expect(
screen.getByRole('menuitem', { name: 'app.editApp', hidden: true }),
).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu-positioner')).toHaveAttribute(
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.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('pointer-events-none')
})
})
@ -1914,7 +1787,7 @@ describe('AppCard', () => {
})
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
@ -1945,7 +1818,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1972,7 +1845,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const openInExploreBtn = screen.getByText('app.openInExplore')
fireEvent.click(openInExploreBtn)
@ -1989,7 +1862,7 @@ describe('AppCard', () => {
const draftTriggerApp = createMockApp({ has_draft_trigger: true })
render(<AppCard app={draftTriggerApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.editApp')).toBeInTheDocument()
// openInExplore should not be shown for draft trigger apps
@ -2014,7 +1887,7 @@ describe('AppCard', () => {
it('should show access control option when webapp_auth is enabled', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.accessControl')).toBeInTheDocument()
})
@ -2028,7 +1901,7 @@ describe('AppCard', () => {
})
render(<AppCard app={appWithReleasePermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.accessControl')).toBeInTheDocument()
@ -2043,7 +1916,7 @@ describe('AppCard', () => {
})
render(<AppCard app={appWithAccessConfigPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
@ -2061,7 +1934,7 @@ describe('AppCard', () => {
})
render(<AppCard app={appWithAccessConfigPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
@ -2077,7 +1950,7 @@ describe('AppCard', () => {
})
render(<AppCard app={appWithAccessConfigPermission} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('common.settings.resourceAccess'))
})
@ -2088,7 +1961,7 @@ describe('AppCard', () => {
it('should click access control button', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
const accessControlBtn = screen.getByText('app.accessControl')
fireEvent.click(accessControlBtn)
@ -2102,7 +1975,7 @@ describe('AppCard', () => {
it('should close access control modal and call onRefresh', async () => {
render(<AppCard app={mockApp} onRefresh={mockOnRefresh} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
@ -2122,7 +1995,7 @@ describe('AppCard', () => {
it('should close access control modal after confirm without onRefresh callback', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
@ -2141,7 +2014,7 @@ describe('AppCard', () => {
it('should show open in explore when userCanAccessApp is true', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
@ -2157,7 +2030,7 @@ describe('AppCard', () => {
render(<AppCard app={workflowApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
expect(screen.getByText('app.openInExplore')).toBeInTheDocument()
})
@ -2175,7 +2048,7 @@ describe('AppCard', () => {
it('should close access control modal when onClose is called', async () => {
render(<AppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(getOperationsTrigger())
await waitFor(() => {
fireEvent.click(screen.getByText('app.accessControl'))
})
@ -2192,120 +2065,4 @@ describe('AppCard', () => {
})
})
})
describe('Delete dialog guards', () => {
const createMockAlertDialogModule = () => ({
AlertDialog: ({
open,
onOpenChange,
children,
}: {
open: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) =>
open ? (
<div role="alertdialog">
<button
type="button"
data-testid="keep-open-dialog"
onClick={() => onOpenChange?.(true)}
>
Keep open
</button>
<button
type="button"
data-testid="force-close-dialog"
onClick={() => onOpenChange?.(false)}
>
Force close
</button>
{children}
</div>
) : null,
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
AlertDialogConfirmButton: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) => (
<button type="button" {...props}>
{children}
</button>
),
})
it('should reset delete input when dialog closes', async () => {
vi.resetModules()
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } })
fireEvent.click(screen.getByTestId('force-close-dialog'))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('textbox')).toHaveValue('')
vi.doUnmock('@langgenius/dify-ui/alert-dialog')
})
it('should keep delete input when dialog remains open', async () => {
vi.resetModules()
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
fireEvent.change(await screen.findByRole('textbox'), { target: { value: 'partial name' } })
fireEvent.click(screen.getByTestId('keep-open-dialog'))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
expect(await screen.findByRole('textbox')).toHaveValue('partial name')
vi.doUnmock('@langgenius/dify-ui/alert-dialog')
})
it('should keep delete dialog open when close is requested during deletion', async () => {
vi.resetModules()
mockDeleteMutationPending = true
vi.doMock('@langgenius/dify-ui/alert-dialog', createMockAlertDialogModule)
const { AppCard: IsolatedAppCard } = await import('../app-card')
render(<IsolatedAppCard app={mockApp} />)
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('force-close-dialog'))
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
vi.doUnmock('@langgenius/dify-ui/alert-dialog')
mockDeleteMutationPending = false
})
})
})

View File

@ -37,23 +37,6 @@ vi.mock('@/next/navigation', () => ({
useParams: vi.fn(() => ({})),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
// Mock Dialog to avoid Base UI focus/portal behavior in tests
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => {
if (!open) return null
return <div data-testid="modal">{children}</div>
},
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div role="dialog" data-testid="modal-content">
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Sidebar mock removed to use real component
const mockAppData: AppData = {

View File

@ -16,23 +16,6 @@ vi.mock('@/app/components/base/chat/chat-with-history/inputs-form/content', () =
default: () => <div data-testid="inputs-form-content">InputsFormContent</div>,
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => import('@/__mocks__/base-ui-dropdown-menu'))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
// Mock Dialog to avoid Base UI focus/portal behavior in tests
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => {
if (!open) return null
return <div data-testid="modal">{children}</div>
},
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div role="dialog" data-testid="modal-content">
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockAppData: AppData = {
app_id: 'app-1',
site: {

View File

@ -85,17 +85,6 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/test',
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal-title">{children}</div>
),
}))
describe('Sidebar Index', () => {
const mockContextValue = {
isInstalledApp: false,

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as ReactI18next from 'react-i18next'
@ -6,13 +5,6 @@ import { expectLoadingButton } from '@/test/button'
import { withSelectorKey } from '@/test/i18n-mock'
import RenameModal from '../rename-modal'
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => <div role="dialog">{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}))
describe('RenameModal', () => {
const defaultProps = {
isShow: true,

View File

@ -5,80 +5,6 @@ import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/
import { TransferMethod } from '@/types/app'
import HumanInputFieldRenderer from '../field-renderer'
function MockTextarea({
value,
onChange,
onValueChange,
...props
}: {
value: string
onChange?: (event: { target: { value: string } }) => void
onValueChange?: (value: string) => void
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
data-testid="content-item-textarea"
value={value}
onChange={(event) => {
onChange?.({ target: { value: event.target.value } })
onValueChange?.(event.target.value)
}}
{...props}
/>
)
}
vi.mock('@langgenius/dify-ui/textarea', () => ({
Textarea: MockTextarea,
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectValueContext = React.createContext<string | null>(null)
return {
Select: ({
children,
onValueChange,
value,
}: {
children: React.ReactNode
onValueChange: (value: string | null) => void
value: string | null
}) => (
<SelectValueContext value={value}>
<div>
<button
type="button"
data-testid="content-item-select-root"
onClick={() => onValueChange('alice')}
>
select alice
</button>
<button
type="button"
data-testid="content-item-select-null"
onClick={() => onValueChange(null)}
>
select null
</button>
{children}
</div>
</SelectValueContext>
),
SelectValue: () => <>{React.use(SelectValueContext)}</>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button" data-testid="content-item-select">
{children}
</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span>selected</span>,
}
})
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
value,
@ -191,34 +117,12 @@ describe('HumanInputFieldRenderer', () => {
/>,
)
await user.click(screen.getByTestId('content-item-select-root'))
await user.click(screen.getByRole('combobox', { name: 'reviewer' }))
await user.click(await screen.findByRole('option', { name: 'alice' }))
expect(onChange).toHaveBeenCalledWith('alice')
})
it('ignores null select values', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<HumanInputFieldRenderer
field={{
type: InputVarType.select,
output_variable_name: 'reviewer',
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
}}
value={null}
onChange={onChange}
/>,
)
expect(screen.getByTestId('content-item-select')).toHaveTextContent('')
await user.click(screen.getByTestId('content-item-select-null'))
expect(onChange).not.toHaveBeenCalled()
})
it('renders single-file input and emits one file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -1,12 +1,10 @@
import type { Resources } from '../index'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import Popup from '../popup'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentDownload: vi.fn(),
}))
@ -65,8 +63,12 @@ const makeData = (overrides: Partial<Resources> = {}): Resources => ({
const openPopup = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId('popup-trigger'))
}
const getDownloadButton = (name = 'report.pdf') => screen.getByRole('button', { name })
const queryDownloadButton = (name = 'report.pdf') => screen.queryByRole('button', { name })
const getDownloadButton = (name = 'report.pdf') =>
within(screen.getByTestId('popup-content')).getByRole('button', { name })
const queryDownloadButton = (name = 'report.pdf') =>
screen.queryByTestId('popup-content')
? within(screen.getByTestId('popup-content')).queryByRole('button', { name })
: null
describe('Popup', () => {
beforeEach(() => {

View File

@ -1,31 +1,9 @@
import type { DatePickerProps } from '../../types'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from '../../utils/dayjs'
import DatePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button
onClick={onClick as (() => void) | undefined}
disabled={disabled as boolean | undefined}
className={className as string | undefined}
>
{children}
</button>
),
}))
// Mock scrollIntoView
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@ -131,19 +109,18 @@ describe('DatePicker', () => {
expect(screen.getByText(/2024/))!.toBeInTheDocument()
})
it('should close when clicking outside the container', () => {
it('should close when clicking outside the container', async () => {
const user = userEvent.setup()
const props = createDatePickerProps()
render(<DatePicker {...props} />)
openPicker()
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
act(() => {
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
await user.click(document.body)
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
expect(screen.queryAllByText(/daysInWeek/)).toHaveLength(0)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})

View File

@ -1,31 +1,9 @@
import type { TimePickerProps } from '../../types'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs, { isDayjsObject } from '../../utils/dayjs'
import TimePicker from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}) => (
<button
onClick={onClick as (() => void) | undefined}
disabled={disabled as boolean | undefined}
className={className as string | undefined}
>
{children}
</button>
),
}))
// Mock scrollIntoView since the test DOM runtime doesn't implement it
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@ -103,15 +81,16 @@ describe('TimePicker', () => {
expect(input)!.toHaveValue('10:00 AM')
})
it('should handle document mousedown listener while picker is open', () => {
it('should close when clicking outside while the picker is open', async () => {
const user = userEvent.setup()
render(<TimePicker {...baseProps} value="10:00 AM" timezone="UTC" />)
const input = screen.getByRole('textbox')
fireEvent.click(input)
expect(input)!.toHaveValue('')
fireEvent.mouseDown(document.body)
expect(input)!.toHaveValue('10:00 AM')
await user.click(document.body)
expect(input).toHaveValue('10:00 AM')
})
it('should call onClear when clear is clicked while picker is closed', () => {

View File

@ -3,8 +3,6 @@ import type { Features } from '../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FeaturesProvider } from '../../../context'
import VoiceSettings from '../voice-settings'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: vi.fn(),
@ -25,25 +23,6 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({
checked,
onCheckedChange,
...props
}: {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button
type="button"
data-testid="switch"
data-checked={String(checked)}
onClick={() => onCheckedChange?.(!checked)}
{...props}
/>
),
}))
const defaultFeatures: Features = {
moreLikeThis: { enabled: false },
opening: { enabled: false },
@ -123,16 +102,4 @@ describe('VoiceSettings', () => {
expect(onOpen).toHaveBeenCalledWith(false)
})
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
renderWithProvider(
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
<button>Settings</button>
</VoiceSettings>,
)
const content = screen.getByTestId('popover-content')
expect(content).toHaveAttribute('data-placement', 'top')
expect(content).toHaveAttribute('data-side-offset', '4')
})
})

View File

@ -1,57 +1,29 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { $getNodeByKey } from 'lexical'
import AgentOutputBlockComponent from '../component'
const { mockEditorFocus, mockEditorUpdate, mockGetRootText, mockSelectNext, mockSetOutput } =
vi.hoisted(() => ({
mockEditorFocus: vi.fn(),
mockEditorUpdate: vi.fn((callback: () => void) => callback()),
mockGetRootText: {
value: '[§output:summary:summary§]',
},
mockSelectNext: vi.fn(),
mockSetOutput: vi.fn(),
}))
const {
mockEditorFocus,
mockEditorUpdate,
mockGetRootText,
mockSelectNext,
mockSetOpenTypeSelectOnEdit,
mockSetOutput,
} = vi.hoisted(() => ({
mockEditorFocus: vi.fn(),
mockEditorUpdate: vi.fn((callback: () => void) => callback()),
mockGetRootText: {
value: '[§output:summary:summary§]',
},
mockSelectNext: vi.fn(),
mockSetOpenTypeSelectOnEdit: vi.fn(),
mockSetOutput: vi.fn(),
}))
vi.mock('@lexical/react/LexicalComposerContext')
vi.mock('@langgenius/dify-ui/select', () => ({
Select: ({
children,
onValueChange,
open,
}: {
children: ReactNode
onValueChange: (value: string) => void
open?: boolean
}) => (
<div>
<span data-testid="type-select-state">{open ? 'open' : 'closed'}</span>
{children}
<button type="button" onClick={() => onValueChange('file')}>
Select file
</button>
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItemIndicator: () => <span />,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectLabel: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectTrigger: ({
children,
onClick,
onMouseDown,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" onClick={onClick} onMouseDown={onMouseDown} {...props}>
{children}
</button>
),
}))
vi.mock('lexical', async (importOriginal) => {
const actual = await importOriginal<typeof import('lexical')>()
@ -69,7 +41,7 @@ vi.mock('lexical', async (importOriginal) => {
})
vi.mock('../node', () => ({
$isAgentOutputBlockNode: () => true,
$isAgentOutputBlockNode: (node: unknown) => Boolean(node),
}))
const outputs: DeclaredOutputConfig[] = [
@ -93,6 +65,7 @@ describe('AgentOutputBlockComponent', () => {
] as unknown as ReturnType<typeof useLexicalComposerContext>)
vi.mocked($getNodeByKey).mockReturnValue({
selectNext: mockSelectNext,
setOpenTypeSelectOnEdit: mockSetOpenTypeSelectOnEdit,
setOutput: mockSetOutput,
} as never)
})
@ -151,7 +124,9 @@ describe('AgentOutputBlockComponent', () => {
/>,
)
expect(screen.getByTestId('type-select-state')).toHaveTextContent('open')
expect(
screen.getByRole('combobox', { name: 'workflow.nodes.agent.outputVars.typeLabel' }),
).toHaveAttribute('aria-expanded', 'true')
})
it('does not update the Lexical node while typing an output name and commits on blur', async () => {
@ -248,7 +223,9 @@ describe('AgentOutputBlockComponent', () => {
false,
)
expect(mockSelectNext).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('type-select-state')).toHaveTextContent('closed')
expect(
screen.getByRole('combobox', { name: 'workflow.nodes.agent.outputVars.typeLabel' }),
).toHaveAttribute('aria-expanded', 'false')
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
@ -300,7 +277,9 @@ describe('AgentOutputBlockComponent', () => {
true,
)
expect(mockSelectNext).not.toHaveBeenCalled()
expect(screen.getByTestId('type-select-state')).toHaveTextContent('open')
expect(
screen.getByRole('combobox', { name: 'workflow.nodes.agent.outputVars.typeLabel' }),
).toHaveAttribute('aria-expanded', 'true')
expect(input).not.toHaveFocus()
expect((input as HTMLInputElement).selectionStart).toBe('summary'.length)
expect((input as HTMLInputElement).selectionEnd).toBe('summary'.length)
@ -374,7 +353,8 @@ describe('AgentOutputBlockComponent', () => {
expect(onChange).not.toHaveBeenCalled()
})
it('does not commit the name blur before selecting an output type', () => {
it('does not commit the name blur before selecting an output type', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
mockGetRootText.value = '[§output:summary:summary§]'
@ -390,17 +370,16 @@ describe('AgentOutputBlockComponent', () => {
)
const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' })
const typeTrigger = screen.getByRole('button', {
const typeTrigger = screen.getByRole('combobox', {
name: 'workflow.nodes.agent.outputVars.typeLabel',
})
fireEvent.change(input, { target: { value: 'summary' } })
fireEvent.mouseDown(typeTrigger)
fireEvent.blur(input)
await user.click(typeTrigger)
expect(onChange).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Select file' }))
await user.click(await screen.findByRole('option', { name: 'file' }))
expect(mockSetOutput).toHaveBeenCalledWith(
'summary',
@ -461,7 +440,7 @@ describe('AgentOutputBlockComponent', () => {
screen.queryByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'workflow.nodes.agent.outputVars.typeLabel' }),
screen.queryByRole('combobox', { name: 'workflow.nodes.agent.outputVars.typeLabel' }),
).not.toBeInTheDocument()
expect(screen.getByText('qna_report_pdf')).toBeInTheDocument()
expect(screen.getByText('file')).toBeInTheDocument()

View File

@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
import ContextBlockComponent from '../component'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
// Mock the hooks used by ContextBlockComponent
const mockUseSelectOrDelete = vi.fn()
const mockUseTrigger = vi.fn()
@ -148,17 +146,6 @@ describe('ContextBlockComponent', () => {
})
describe('User Interactions', () => {
it('should keep the popover closed when the trigger prevents the default click', async () => {
const user = userEvent.setup()
const { triggerSetOpen } = defaultSetup()
render(<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />)
await user.click(screen.getByTestId('popover-trigger'))
expect(triggerSetOpen).not.toHaveBeenCalled()
expect(screen.queryByText('common.promptEditor.context.modal.add')).not.toBeInTheDocument()
})
it('should call onAddContext when add button is clicked', async () => {
defaultSetup({ open: true })
const handleAddContext = vi.fn()

View File

@ -6,8 +6,6 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
import HistoryBlockComponent from '../component'
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
type HistoryEventPayload = {
type?: string
payload?: RoleName
@ -99,19 +97,6 @@ describe('HistoryBlockComponent', () => {
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
})
it('should keep the popover closed when the trigger prevents the default click', async () => {
const user = userEvent.setup()
const setOpen = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
render(<HistoryBlockComponent nodeKey="history-node-trigger" onEditRole={vi.fn()} />)
await user.click(screen.getByTestId('popover-trigger'))
expect(setOpen).not.toHaveBeenCalled()
expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument()
})
it('should call onEditRole when edit action is clicked', async () => {
const user = userEvent.setup()
const onEditRole = vi.fn()

View File

@ -1,31 +1,15 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { render } from '@/test/console/render'
import { Plan } from '../../type'
import Pricing from '../index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
let mockConsoleState: Record<string, unknown> = {}
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('../header', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<button type="button" onClick={onClose}>
@ -72,7 +56,6 @@ const buildUsage = (): UsagePlanInfo => ({
describe('Pricing dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
mockConsoleState = {
isCurrentWorkspaceManager: true,
}
@ -86,12 +69,12 @@ describe('Pricing dialog lifecycle', () => {
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
})
it('should only call onCancel when the dialog requests closing', () => {
it('should call onCancel when the pricing dialog is closed', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(<Pricing onCancel={onCancel} />)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
await user.click(screen.getByRole('button', { name: 'close' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})

View File

@ -1,515 +1,47 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PreviewDocumentPicker from '../preview-document-picker'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Factory function to create mock DocumentItem
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
id: `doc-${Math.random().toString(36).substr(2, 9)}`,
name: 'Test Document',
extension: 'txt',
...overrides,
})
// Factory function to create multiple document items
const createMockDocumentList = (count: number): DocumentItem[] => {
return Array.from({ length: count }, (_, index) =>
createMockDocumentItem({
id: `doc-${index + 1}`,
name: `Document ${index + 1}`,
extension: index % 2 === 0 ? 'pdf' : 'txt',
}),
)
}
// Factory function to create default props
const createDefaultProps = (
overrides: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {},
) => ({
value: createMockDocumentItem({ id: 'selected-doc', name: 'Selected Document' }),
files: createMockDocumentList(3),
onChange: vi.fn(),
...overrides,
})
// Helper to render component with default props
const renderComponent = (
props: Partial<React.ComponentProps<typeof PreviewDocumentPicker>> = {},
) => {
const defaultProps = createDefaultProps(props)
return {
...render(<PreviewDocumentPicker {...defaultProps} />),
props: defaultProps,
}
}
const openPopover = () => {
fireEvent.click(screen.getByTestId('popover-trigger'))
}
const documents: DocumentItem[] = [
{ id: 'document-1', name: 'First document', extension: 'pdf' } as DocumentItem,
{ id: 'document-2', name: 'Second document', extension: 'txt' } as DocumentItem,
]
describe('PreviewDocumentPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
it('renders the selected document name', () => {
render(<PreviewDocumentPicker value={documents[0]} files={documents} onChange={vi.fn()} />)
expect(screen.getByRole('button', { name: /First document/ })).toHaveAttribute(
'aria-expanded',
'false',
)
})
// Tests for basic rendering
describe('Rendering', () => {
it('should render document name from value prop', () => {
renderComponent({
value: createMockDocumentItem({ name: 'My Document' }),
})
it('opens the document list and selects a document', async () => {
const onChange = vi.fn()
render(<PreviewDocumentPicker value={documents[0]} files={documents} onChange={onChange} />)
expect(screen.getByText('My Document')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /First document/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Second document' }))
it('should render placeholder when name is empty', () => {
renderComponent({
value: createMockDocumentItem({ name: '' }),
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render placeholder when name is undefined', () => {
renderComponent({
value: { id: 'doc-1', extension: 'txt' } as DocumentItem,
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render file icon', () => {
renderComponent({
value: createMockDocumentItem({ extension: 'txt' }),
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
it('should render pdf icon for pdf extension', () => {
renderComponent({
value: createMockDocumentItem({ extension: 'pdf' }),
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
expect(onChange).toHaveBeenCalledWith(documents[1])
expect(screen.getByRole('button', { name: /First document/ })).toHaveAttribute(
'aria-expanded',
'false',
)
})
// Tests for props handling
describe('Props', () => {
it('should accept required props', () => {
const props = createDefaultProps()
render(<PreviewDocumentPicker {...props} />)
it('renders a placeholder without a selected document', () => {
render(<PreviewDocumentPicker files={documents} onChange={vi.fn()} />)
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle single file', () => {
// Component should accept single file
renderComponent({
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle multiple files', () => {
// Component should accept multiple files
renderComponent({
files: createMockDocumentList(5),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should use value.extension for file icon', () => {
renderComponent({
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
})
const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /--/ })).toBeInTheDocument()
})
// Tests for state management
describe('State Management', () => {
it('should initialize with popup closed', () => {
renderComponent()
it('shows loading content when the file list is empty', async () => {
render(<PreviewDocumentPicker value={documents[0]} files={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
})
fireEvent.click(screen.getByRole('button', { name: /First document/ }))
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
it('should render portal content for document selection', () => {
renderComponent()
openPopover()
// Popover content is rendered after opening the trigger in our mock
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
})
})
// Tests for callback stability and memoization
describe('Callback Stability', () => {
it('should maintain stable onChange callback when value changes', () => {
const onChange = vi.fn()
const value1 = createMockDocumentItem({ id: 'doc-1', name: 'Doc 1' })
const value2 = createMockDocumentItem({ id: 'doc-2', name: 'Doc 2' })
const { rerender } = render(
<PreviewDocumentPicker
value={value1}
files={createMockDocumentList(3)}
onChange={onChange}
/>,
)
rerender(
<PreviewDocumentPicker
value={value2}
files={createMockDocumentList(3)}
onChange={onChange}
/>,
)
expect(screen.getByText('Doc 2')).toBeInTheDocument()
})
it('should use updated onChange callback after rerender', () => {
const onChange1 = vi.fn()
const onChange2 = vi.fn()
const value = createMockDocumentItem()
const files = createMockDocumentList(3)
const { rerender } = render(
<PreviewDocumentPicker value={value} files={files} onChange={onChange1} />,
)
rerender(<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />)
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
// Tests for component memoization
describe('Component Memoization', () => {
it('should not re-render when props are the same', () => {
const onChange = vi.fn()
const value = createMockDocumentItem()
const files = createMockDocumentList(3)
const { rerender } = render(
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
)
rerender(<PreviewDocumentPicker value={value} files={files} onChange={onChange} />)
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
// Tests for user interactions
describe('User Interactions', () => {
it('should toggle popup when trigger is clicked', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
it('should render document list with files', () => {
const files = createMockDocumentList(3)
renderComponent({ files })
openPopover()
// Documents should be visible in the list
expect(screen.getByText('Document 1')).toBeInTheDocument()
expect(screen.getByText('Document 2')).toBeInTheDocument()
expect(screen.getByText('Document 3')).toBeInTheDocument()
})
it('should call onChange when document is selected', () => {
const onChange = vi.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 2'))
// handleChange should call onChange with the selected item
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(files[1])
})
it('should handle rapid toggle clicks', () => {
renderComponent()
const trigger = screen.getByTestId('popover-trigger')
// Rapid clicks
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
fireEvent.click(trigger)
expect(trigger).toBeInTheDocument()
})
})
// Tests for edge cases
describe('Edge Cases', () => {
it('should handle null value properties gracefully', () => {
renderComponent({
value: { id: 'doc-1', name: '', extension: '' },
})
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render when value prop is omitted (optional)', () => {
const files = createMockDocumentList(2)
const onChange = vi.fn()
// Do not pass `value` at all to verify optional behavior
render(<PreviewDocumentPicker files={files} onChange={onChange} />)
// Renders placeholder for missing name
expect(screen.getByText('--')).toBeInTheDocument()
// Portal wrapper renders
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle very long document names', () => {
const longName = 'A'.repeat(500)
renderComponent({
value: createMockDocumentItem({ name: longName }),
})
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in document name', () => {
const specialName = '<script>alert("xss")</script>'
renderComponent({
value: createMockDocumentItem({ name: specialName }),
})
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle large number of files', () => {
const manyFiles = createMockDocumentList(100)
renderComponent({ files: manyFiles })
// Component should accept large files array
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle files with same name but different extensions', () => {
const files = [
createMockDocumentItem({ id: 'doc-1', name: 'document', extension: 'pdf' }),
createMockDocumentItem({ id: 'doc-2', name: 'document', extension: 'txt' }),
]
renderComponent({ files })
// Component should handle duplicate names
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
// Tests for prop variations
describe('Prop Variations', () => {
describe('value variations', () => {
it('should handle value with all fields', () => {
renderComponent({
value: {
id: 'full-doc',
name: 'Full Document',
extension: 'pdf',
},
})
expect(screen.getByText('Full Document')).toBeInTheDocument()
})
it('should handle value with minimal fields', () => {
renderComponent({
value: { id: 'minimal', name: '', extension: '' },
})
expect(screen.getByText('--')).toBeInTheDocument()
})
})
describe('files variations', () => {
it('should handle single file', () => {
renderComponent({
files: [createMockDocumentItem({ name: 'Single' })],
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle two files', () => {
renderComponent({
files: createMockDocumentList(2),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
it('should handle many files', () => {
renderComponent({
files: createMockDocumentList(50),
})
expect(screen.getByTestId('popover')).toBeInTheDocument()
})
})
describe('extension variations', () => {
const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'md']
it.each(extensions)('should render icon for %s extension', (ext) => {
renderComponent({
value: createMockDocumentItem({ extension: ext }),
files: [], // Use empty files to avoid duplicate icons
})
const trigger = screen.getByTestId('popover-trigger')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
})
})
// Tests for document list rendering
describe('Document List Rendering', () => {
it('should render all documents in the list', () => {
const files = createMockDocumentList(5)
renderComponent({ files })
openPopover()
// All documents should be visible
files.forEach((file) => {
expect(screen.getByText(file.name)).toBeInTheDocument()
})
})
it('should pass onChange handler to DocumentList', () => {
const onChange = vi.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
})
it('should show count header only for multiple files', () => {
// Single file - no header
const { rerender } = render(
<PreviewDocumentPicker
value={createMockDocumentItem()}
files={[createMockDocumentItem({ name: 'Single File' })]}
onChange={vi.fn()}
/>,
)
expect(screen.queryByText(/files/)).not.toBeInTheDocument()
// Multiple files - show header
rerender(
<PreviewDocumentPicker
value={createMockDocumentItem()}
files={createMockDocumentList(3)}
onChange={vi.fn()}
/>,
)
openPopover()
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
})
})
// Tests for visual states
describe('Visual States', () => {
it('should have max-width on name element', () => {
renderComponent({
value: createMockDocumentItem({ name: 'Test' }),
})
const nameElement = screen.getByText('Test')
expect(nameElement).toHaveClass('max-w-50')
})
})
// Tests for handleChange callback
describe('handleChange Callback', () => {
it('should call onChange with selected document item', () => {
const onChange = vi.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
openPopover()
fireEvent.click(screen.getByText('Document 1'))
expect(onChange).toHaveBeenCalledWith(files[0])
})
it('should handle different document items in files', () => {
const onChange = vi.fn()
const customFiles = [
{ id: 'custom-1', name: 'Custom File 1', extension: 'pdf' },
{ id: 'custom-2', name: 'Custom File 2', extension: 'txt' },
]
renderComponent({ files: customFiles, onChange })
openPopover()
fireEvent.click(screen.getByText('Custom File 1'))
expect(onChange).toHaveBeenCalledWith(customFiles[0])
openPopover()
fireEvent.click(screen.getByText('Custom File 2'))
expect(onChange).toHaveBeenCalledWith(customFiles[1])
})
it('should work with multiple sequential selections', () => {
const onChange = vi.fn()
const files = createMockDocumentList(3)
renderComponent({ files, onChange })
// Select multiple documents sequentially
openPopover()
fireEvent.click(screen.getByText('Document 1'))
openPopover()
fireEvent.click(screen.getByText('Document 3'))
openPopover()
fireEvent.click(screen.getByText('Document 2'))
expect(onChange).toHaveBeenCalledTimes(3)
expect(onChange).toHaveBeenNthCalledWith(1, files[0])
expect(onChange).toHaveBeenNthCalledWith(2, files[2])
expect(onChange).toHaveBeenNthCalledWith(3, files[1])
})
expect(await screen.findByRole('dialog')).toBeInTheDocument()
})
})

View File

@ -115,24 +115,6 @@ vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({
),
}))
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean
onCheckedChange?: (v: boolean) => void
}) => (
<button
data-testid="rerank-switch"
data-checked={checked}
onClick={() => onCheckedChange?.(!checked)}
>
Switch
</button>
),
}))
describe('RetrievalParamConfig', () => {
const createDefaultConfig = (overrides?: Partial<RetrievalConfig>): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
@ -166,7 +148,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByTestId('rerank-switch'))!.toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('should render model selector when reranking is enabled', () => {
@ -232,7 +214,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
fireEvent.click(screen.getByTestId('rerank-switch'))
fireEvent.click(screen.getByRole('switch'))
expect(mockOnChange).toHaveBeenCalledWith({
...config,
@ -251,7 +233,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
fireEvent.click(screen.getByTestId('rerank-switch'))
fireEvent.click(screen.getByRole('switch'))
expect(mockNotify).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
})
@ -375,7 +357,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.getByTestId('rerank-switch'))!.toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('should hide score threshold when reranking is disabled for full text search', () => {
@ -422,7 +404,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.queryByTestId('rerank-switch')).not.toBeInTheDocument()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should not render model selector for keyword search', () => {
@ -762,7 +744,7 @@ describe('RetrievalParamConfig', () => {
/>,
)
expect(screen.queryByTestId('rerank-switch')).not.toBeInTheDocument()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('should update model selection for hybrid search', () => {

View File

@ -110,30 +110,6 @@ describe('LanguageSelect', () => {
it('should ignore null values emitted by the select control', async () => {
vi.resetModules()
vi.doMock('@langgenius/dify-ui/select', () => ({
Select: ({
onValueChange,
children,
}: {
onValueChange?: (value: string | null) => void
children: React.ReactNode
}) => {
React.useEffect(() => {
onValueChange?.(null)
}, [onValueChange])
return <div>{children}</div>
},
SelectTrigger: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
SelectValue: ({ placeholder }: { placeholder?: React.ReactNode }) => <>{placeholder}</>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => null,
}))
const { default: IsolatedLanguageSelect } = await import('../index')
const onSelect = vi.fn()

View File

@ -1,830 +1,106 @@
import type { CredentialSelectorProps } from '../index'
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CredentialSelector from '../index'
// Mock CredentialTypeEnum to avoid deep import chain issues
const MockCredentialTypeEnum = {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
} as const
// Mock plugin-auth module to avoid deep import chain issues
vi.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
CredentialTypeEnum: { OAUTH2: 'oauth2', API_KEY: 'api_key' },
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// CredentialIcon - imported directly (not mocked)
// This is a simple UI component with no external dependencies
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}),
)
const createDefaultProps = (
overrides?: Partial<CredentialSelectorProps>,
): CredentialSelectorProps => ({
currentCredentialId: 'cred-1',
onCredentialChange: vi.fn(),
credentials: createMockCredentials(),
...overrides,
})
const credentials = [
{
id: 'credential-1',
name: 'First credential',
avatar_url: 'https://example.com/first.png',
type: 'oauth2',
},
{
id: 'credential-2',
name: 'Second credential',
avatar_url: 'https://example.com/second.png',
type: 'oauth2',
},
] as DataSourceCredential[]
describe('CredentialSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
it('renders the current credential as an accessible trigger', () => {
render(
<CredentialSelector
currentCredentialId="credential-1"
credentials={credentials}
onCredentialChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /First credential/ })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/first.png')
})
// Rendering Tests - Verify component renders correctly
describe('Rendering', () => {
it('should render current credential name in trigger', () => {
const props = createDefaultProps()
it('selects the first credential when the current id is invalid', async () => {
const onCredentialChange = vi.fn()
render(
<CredentialSelector
currentCredentialId="missing"
credentials={credentials}
onCredentialChange={onCredentialChange}
/>,
)
render(<CredentialSelector {...props} />)
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
})
it('should render credential icon with correct props', () => {
const props = createDefaultProps()
const { container } = render(<CredentialSelector {...props} />)
// Assert - CredentialIcon renders an img when avatarUrl is provided
const iconImg = container.querySelector('img')
expect(iconImg)!.toBeInTheDocument()
expect(iconImg)!.toHaveAttribute('src', 'https://example.com/avatar-1.png')
})
it('should render dropdown arrow icon', () => {
const props = createDefaultProps()
const { container } = render(<CredentialSelector {...props} />)
const svgIcon = container.querySelector('svg')
expect(svgIcon)!.toBeInTheDocument()
})
it('should not render dropdown content initially', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
it('should render all credentials in dropdown when opened', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
// Act - Click trigger to open dropdown
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(trigger.firstElementChild).toHaveClass('bg-state-base-hover')
// Assert - All credentials should be visible (current credential appears in both trigger and list)
// Assert - All credentials should be visible (current credential appears in both trigger and list)
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
// 3 in dropdown list + 1 in trigger (current) = 4 total
expect(screen.getAllByText(/Credential \d/)).toHaveLength(4)
})
await waitFor(() => expect(onCredentialChange).toHaveBeenCalledWith('credential-1'))
})
// Props Testing - Verify all prop variations
describe('Props', () => {
describe('currentCredentialId prop', () => {
it('should display first credential when currentCredentialId matches first', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
it('does not select a fallback for an empty credential list', () => {
const onCredentialChange = vi.fn()
render(
<CredentialSelector
currentCredentialId="missing"
credentials={[]}
onCredentialChange={onCredentialChange}
/>,
)
render(<CredentialSelector {...props} />)
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
})
it('should display second credential when currentCredentialId matches second', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
expect(screen.getByText('Credential 2'))!.toBeInTheDocument()
})
it('should display third credential when currentCredentialId matches third', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-3' })
render(<CredentialSelector {...props} />)
expect(screen.getByText('Credential 3'))!.toBeInTheDocument()
})
it.each([
['cred-1', 'Credential 1'],
['cred-2', 'Credential 2'],
['cred-3', 'Credential 3'],
])(
'should display %s credential name when currentCredentialId is %s',
(credId, expectedName) => {
const props = createDefaultProps({ currentCredentialId: credId })
render(<CredentialSelector {...props} />)
expect(screen.getByText(expectedName))!.toBeInTheDocument()
},
)
})
describe('credentials prop', () => {
it('should render single credential correctly', () => {
const props = createDefaultProps({
credentials: [createMockCredential()],
currentCredentialId: 'cred-1',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText('Test Credential'))!.toBeInTheDocument()
})
it('should render multiple credentials in dropdown', () => {
const props = createDefaultProps({
credentials: createMockCredentials(5),
currentCredentialId: 'cred-1',
})
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - 5 in dropdown + 1 in trigger (current credential appears twice)
expect(screen.getAllByText(/Credential \d/).length).toBe(6)
})
it('should handle credentials with special characters in name', () => {
const props = createDefaultProps({
credentials: [
createMockCredential({ id: 'cred-special', name: 'Test & Credential <special>' }),
],
currentCredentialId: 'cred-special',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText('Test & Credential <special>'))!.toBeInTheDocument()
})
})
describe('onCredentialChange prop', () => {
it('should be called when selecting a credential', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act - Open dropdown
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
it.each([
['cred-2', 'Credential 2'],
['cred-3', 'Credential 3'],
])('should call onCredentialChange with %s when selecting %s', (credId, credentialName) => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select credential
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Get the dropdown item using within() to scope query to portal content
const portalContent = screen.getByTestId('popover-content')
const credentialOption = within(portalContent).getByText(credentialName)
fireEvent.click(credentialOption)
expect(mockOnChange).toHaveBeenCalledWith(credId)
})
it('should call onCredentialChange with cred-1 when selecting Credential 1 in dropdown', () => {
// Arrange - Start with cred-2 selected so cred-1 is only in dropdown
const mockOnChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnChange,
currentCredentialId: 'cred-2',
})
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select Credential 1
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential1 = screen.getByText('Credential 1')
fireEvent.click(credential1)
expect(mockOnChange).toHaveBeenCalledWith('cred-1')
})
})
expect(onCredentialChange).not.toHaveBeenCalled()
})
// User Interactions - Test event handlers
describe('User Interactions', () => {
it('should toggle dropdown open when trigger is clicked', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
it('opens the real popover and selects a credential', async () => {
const onCredentialChange = vi.fn()
render(
<CredentialSelector
currentCredentialId="credential-1"
credentials={credentials}
onCredentialChange={onCredentialChange}
/>,
)
const trigger = screen.getByRole('button', { name: /First credential/ })
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
// Assert - Initially closed
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(await screen.findByText('Second credential'))
// Act - Click trigger
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Now open
// Assert - Now open
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
})
it('should call onCredentialChange when clicking a credential item', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
it('should close dropdown after selecting a credential', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act - Open and select
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert - The handleCredentialChange calls toggle(), which should change the open state
expect(mockOnChange).toHaveBeenCalled()
})
it('should allow selecting credentials multiple times', () => {
// Arrange - Start with cred-2 selected so we can select other credentials
const mockOnChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnChange,
currentCredentialId: 'cred-2',
})
render(<CredentialSelector {...props} />)
// Act & Assert - Select Credential 1 (different from current)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential1 = screen.getByText('Credential 1')
fireEvent.click(credential1)
expect(mockOnChange).toHaveBeenCalledWith('cred-1')
})
expect(onCredentialChange).toHaveBeenCalledWith('credential-2')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
})
// Side Effects and Cleanup - Test useEffect behavior
describe('Side Effects and Cleanup', () => {
it('should auto-select first credential when currentCredential is not found and credentials exist', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'non-existent-id',
onCredentialChange: mockOnChange,
})
render(<CredentialSelector {...props} />)
// Assert - Should auto-select first credential
expect(mockOnChange).toHaveBeenCalledWith('cred-1')
})
it('should not call onCredentialChange when currentCredential is found', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'cred-2',
onCredentialChange: mockOnChange,
})
render(<CredentialSelector {...props} />)
// Assert - Should not auto-select
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should not call onCredentialChange when credentials array is empty', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'cred-1',
credentials: [],
onCredentialChange: mockOnChange,
})
render(<CredentialSelector {...props} />)
// Assert - Should not call since no credentials to select
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should auto-select when credentials change and currentCredential becomes invalid', async () => {
const mockOnChange = vi.fn()
const initialCredentials = createMockCredentials(3)
const props = createDefaultProps({
currentCredentialId: 'cred-1',
credentials: initialCredentials,
onCredentialChange: mockOnChange,
})
const { rerender } = render(<CredentialSelector {...props} />)
expect(mockOnChange).not.toHaveBeenCalled()
// Act - Change credentials to not include current
const newCredentials = [
createMockCredential({ id: 'cred-4', name: 'New Credential 4' }),
createMockCredential({ id: 'cred-5', name: 'New Credential 5' }),
]
rerender(<CredentialSelector {...props} credentials={newCredentials} />)
// Assert - Should auto-select first of new credentials
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('cred-4')
})
})
it('should not trigger auto-select effect on every render with same props', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
// Act - Render and rerender with same props
const { rerender } = render(<CredentialSelector {...props} />)
rerender(<CredentialSelector {...props} />)
rerender(<CredentialSelector {...props} />)
// Assert - onCredentialChange should not be called for auto-selection
expect(mockOnChange).not.toHaveBeenCalled()
})
})
// Callback Stability and Memoization - Test useCallback behavior
describe('Callback Stability and Memoization', () => {
it('should have stable handleCredentialChange callback', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
// Act - Open dropdown and select
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
// Assert - Callback should work correctly
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
it('should update handleCredentialChange when onCredentialChange changes', () => {
const mockOnChange1 = vi.fn()
const mockOnChange2 = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange1 })
const { rerender } = render(<CredentialSelector {...props} />)
// Act - Update onCredentialChange prop
rerender(<CredentialSelector {...props} onCredentialChange={mockOnChange2} />)
// Open and select
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
// Assert - New callback should be used
expect(mockOnChange1).not.toHaveBeenCalled()
expect(mockOnChange2).toHaveBeenCalledWith('cred-2')
})
})
// Memoization Logic and Dependencies - Test useMemo behavior
describe('Memoization Logic and Dependencies', () => {
it('should find currentCredential by id', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
// Assert - Should display credential 2
// Assert - Should display credential 2
expect(screen.getByText('Credential 2'))!.toBeInTheDocument()
})
it('should update currentCredential when currentCredentialId changes', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
// Assert initial
// Assert initial
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
// Act - Change currentCredentialId
rerender(<CredentialSelector {...props} currentCredentialId="cred-3" />)
// Assert - Should now display credential 3
// Assert - Should now display credential 3
expect(screen.getByText('Credential 3'))!.toBeInTheDocument()
})
it('should update currentCredential when credentials array changes', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
// Assert initial
// Assert initial
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
// Act - Change credentials
const newCredentials = [createMockCredential({ id: 'cred-1', name: 'Updated Credential 1' })]
rerender(<CredentialSelector {...props} credentials={newCredentials} />)
// Assert - Should display updated name
// Assert - Should display updated name
expect(screen.getByText('Updated Credential 1'))!.toBeInTheDocument()
})
it('should return undefined currentCredential when id not found', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({
currentCredentialId: 'non-existent',
onCredentialChange: mockOnChange,
})
render(<CredentialSelector {...props} />)
// Assert - Should trigger auto-select effect
expect(mockOnChange).toHaveBeenCalledWith('cred-1')
})
})
// Component Memoization - Test React.memo behavior
describe('Component Memoization', () => {
it('should not re-render when props remain the same', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
const renderSpy = vi.fn()
const TrackedCredentialSelector: React.FC<CredentialSelectorProps> = (trackedProps) => {
renderSpy()
return <CredentialSelector {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedCredentialSelector)
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when currentCredentialId changes', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<CredentialSelector {...props} />)
// Assert initial
// Assert initial
expect(screen.getByText('Credential 1'))!.toBeInTheDocument()
rerender(<CredentialSelector {...props} currentCredentialId="cred-2" />)
expect(screen.getByText('Credential 2'))!.toBeInTheDocument()
})
it('should re-render when credentials array reference changes', () => {
const props = createDefaultProps()
const { rerender } = render(<CredentialSelector {...props} />)
// Act - Create new credentials array with different data
const newCredentials = [createMockCredential({ id: 'cred-1', name: 'New Name 1' })]
rerender(<CredentialSelector {...props} credentials={newCredentials} />)
expect(screen.getByText('New Name 1'))!.toBeInTheDocument()
})
it('should re-render when onCredentialChange reference changes', () => {
const mockOnChange1 = vi.fn()
const mockOnChange2 = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange1 })
const { rerender } = render(<CredentialSelector {...props} />)
// Act - Change callback reference
rerender(<CredentialSelector {...props} onCredentialChange={mockOnChange2} />)
// Open and select
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential = screen.getByText('Credential 2')
fireEvent.click(credential)
// Assert - New callback should be used
expect(mockOnChange2).toHaveBeenCalledWith('cred-2')
})
})
describe('Edge Cases and Error Handling', () => {
it('should handle undefined avatar_url in credential', () => {
const credentialWithoutAvatar = createMockCredential({
id: 'cred-no-avatar',
name: 'No Avatar Credential',
avatar_url: undefined,
})
const props = createDefaultProps({
credentials: [credentialWithoutAvatar],
currentCredentialId: 'cred-no-avatar',
})
const { container } = render(<CredentialSelector {...props} />)
expect(screen.getByText('No Avatar Credential'))!.toBeInTheDocument()
const iconImg = container.querySelector('img')
expect(iconImg).not.toBeInTheDocument()
expect(screen.getByText('N'))!.toBeInTheDocument()
})
it('should handle very long credential name', () => {
const longName = 'A'.repeat(200)
const credentialWithLongName = createMockCredential({
id: 'cred-long-name',
name: longName,
})
const props = createDefaultProps({
credentials: [credentialWithLongName],
currentCredentialId: 'cred-long-name',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText(longName))!.toBeInTheDocument()
})
it('should handle special characters in credential name', () => {
const specialName = '测试 Credential <script>alert("xss")</script> & "quoted"'
const credentialWithSpecialName = createMockCredential({
id: 'cred-special',
name: specialName,
})
const props = createDefaultProps({
credentials: [credentialWithSpecialName],
currentCredentialId: 'cred-special',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText(specialName))!.toBeInTheDocument()
})
it('should handle numeric id as string', () => {
const credentialWithNumericId = createMockCredential({
id: '123456',
name: 'Numeric ID Credential',
})
const props = createDefaultProps({
credentials: [credentialWithNumericId],
currentCredentialId: '123456',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText('Numeric ID Credential'))!.toBeInTheDocument()
})
it('should handle large number of credentials', () => {
const manyCredentials = createMockCredentials(100)
const props = createDefaultProps({
credentials: manyCredentials,
currentCredentialId: 'cred-50',
})
render(<CredentialSelector {...props} />)
expect(screen.getByText('Credential 50'))!.toBeInTheDocument()
})
it('should handle credential selection with duplicate names', () => {
const mockOnChange = vi.fn()
const duplicateCredentials = [
createMockCredential({ id: 'cred-1', name: 'Same Name' }),
createMockCredential({ id: 'cred-2', name: 'Same Name' }),
]
const props = createDefaultProps({
credentials: duplicateCredentials,
currentCredentialId: 'cred-1',
onCredentialChange: mockOnChange,
})
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Get all "Same Name" elements
// 1 in trigger (current) + 2 in dropdown (both credentials) = 3 total
const sameNameElements = screen.getAllByText('Same Name')
expect(sameNameElements.length).toBe(3)
fireEvent.click(sameNameElements[2]!)
// Assert - Should call with the correct id even with duplicate names
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
it('ignores credential clicks after unmount', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
const { unmount } = render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
unmount()
// Assert - Should not throw
expect(() => {
// Any cleanup should have happened
}).not.toThrow()
})
})
// Styling and CSS Classes
describe('Styling', () => {
it('should configure dropdown placement through popover props', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const content = screen.getByTestId('popover-content')
expect(content)!.toHaveAttribute('data-placement', 'bottom-start')
expect(content)!.toHaveAttribute('data-side-offset', '4')
expect(content)!.not.toHaveClass('z-10')
})
})
// Integration with Child Components
describe('Integration with Child Components', () => {
it('should pass currentCredential to Trigger component', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
// Assert - Trigger should display the correct credential
// Assert - Trigger should display the correct credential
expect(screen.getByText('Credential 2'))!.toBeInTheDocument()
})
it('should pass isOpen state to Trigger component', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
// Assert - Initially closed
const portalRoot = screen.getByTestId('popover')
expect(portalRoot)!.toHaveAttribute('data-open', 'false')
// Act - Open
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Now open
// Assert - Now open
expect(portalRoot)!.toHaveAttribute('data-open', 'true')
})
it('should pass credentials to List component', () => {
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - All credentials should be rendered in list
// 3 in dropdown + 1 in trigger (current credential appears twice) = 4 total
const credentialNames = screen.getAllByText(/Credential \d/)
expect(credentialNames.length).toBe(4)
})
it('should pass currentCredentialId to List component', () => {
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
// Assert - Current credential (Credential 2) appears twice:
// once in trigger and once in dropdown list
const credential2Elements = screen.getAllByText('Credential 2')
expect(credential2Elements.length).toBe(2)
})
it('should pass handleCredentialChange to List component', () => {
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<CredentialSelector {...props} />)
const trigger = screen.getByTestId('popover-trigger')
fireEvent.click(trigger)
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert - handleCredentialChange should propagate the call
expect(mockOnChange).toHaveBeenCalledWith('cred-3')
})
})
// Popover Configuration
describe('Popover Configuration', () => {
it('should configure Popover with placement bottom-start', () => {
// This test verifies the portal is configured correctly
// The actual placement is handled by the mock, but we verify the component renders
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('should configure Popover with offset mainAxis 4', () => {
// This test verifies the offset configuration doesn't break rendering
const props = createDefaultProps()
render(<CredentialSelector {...props} />)
expect(screen.getByTestId('popover'))!.toBeInTheDocument()
})
it('reflects an updated controlled credential', () => {
const onCredentialChange = vi.fn()
const { rerender } = render(
<CredentialSelector
currentCredentialId="credential-1"
credentials={credentials}
onCredentialChange={onCredentialChange}
/>,
)
rerender(
<CredentialSelector
currentCredentialId="credential-2"
credentials={credentials}
onCredentialChange={onCredentialChange}
/>,
)
expect(screen.getByRole('button', { name: /Second credential/ })).toBeInTheDocument()
})
})

View File

@ -2,24 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from '../header'
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
variant,
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
variant: string
}) => (
<button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
describe('Header', () => {
const defaultProps = {
onReset: vi.fn(),
@ -41,29 +23,35 @@ describe('Header', () => {
it('should render reset and preview buttons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('btn-ghost')).toBeInTheDocument()
expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.reset' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'datasetPipeline.addDocuments.stepTwo.previewChunks' }),
).toBeInTheDocument()
})
it('should call onReset when reset clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-ghost'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.reset' }))
expect(defaultProps.onReset).toHaveBeenCalled()
})
it('should call onPreview when preview clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-secondary-accent'))
fireEvent.click(
screen.getByRole('button', { name: 'datasetPipeline.addDocuments.stepTwo.previewChunks' }),
)
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should disable reset button when resetDisabled is true', () => {
render(<Header {...defaultProps} resetDisabled={true} />)
expect(screen.getByTestId('btn-ghost')).toBeDisabled()
expect(screen.getByRole('button', { name: 'common.operation.reset' })).toBeDisabled()
})
it('should disable preview button when previewDisabled is true', () => {
render(<Header {...defaultProps} previewDisabled={true} />)
expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled()
expect(
screen.getByRole('button', { name: 'datasetPipeline.addDocuments.stepTwo.previewChunks' }),
).toBeDisabled()
})
})

View File

@ -248,35 +248,6 @@ vi.mock('@/app/components/base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({
page,
totalPages,
onPageChange,
pageSize,
}: {
page: number
totalPages: number
onPageChange: (page: number) => void
pageSize?: {
onValueChange: (limit: number) => void
}
}) => (
<div data-testid="pagination">
<span data-testid="current-page">{page - 1}</span>
<span data-testid="total-pages">{totalPages}</span>
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>
Next
</button>
{pageSize && (
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>
Change Limit
</button>
)}
</div>
),
}))
const createMockSegmentDetail = (
overrides: Partial<SegmentDetailModel> = {},
): SegmentDetailModel => ({
@ -500,7 +471,7 @@ describe('Completed Component', () => {
it('should render Pagination component', () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('pagination'))!.toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
})
it('should render Divider component', () => {
@ -525,32 +496,35 @@ describe('Completed Component', () => {
})
describe('Pagination', () => {
it('should start with page 0 (current - 1)', () => {
it('should start on the first page', () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('current-page'))!.toHaveTextContent('0')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'1',
)
})
it('should update page when pagination changes', async () => {
mockSegmentListData.total = 30
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
const nextPageButton = screen.getByTestId('next-page')
const nextPageButton = screen.getByRole('button', { name: 'common.pagination.next' })
fireEvent.click(nextPageButton)
await waitFor(() => {
expect(screen.getByTestId('current-page'))!.toHaveTextContent('1')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'2',
)
})
})
it('should update limit when limit changes', async () => {
it('should expose page-size controls', () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
const changeLimitButton = screen.getByTestId('change-limit')
fireEvent.click(changeLimitButton)
// Limit change is handled internally
// Limit change is handled internally
expect(changeLimitButton)!.toBeInTheDocument()
expect(screen.getByRole('group', { name: 'common.pagination.perPage' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '10' })).toHaveAttribute('aria-pressed', 'true')
})
})
@ -651,7 +625,7 @@ describe('Edge Cases', () => {
const { unmount } = render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('pagination'))!.toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
unmount()
})
@ -667,7 +641,7 @@ describe('Edge Cases', () => {
const { unmount } = render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('pagination'))!.toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
unmount()
})
@ -696,7 +670,7 @@ describe('Integration Tests', () => {
// All components should render without errors
expect(screen.getByTestId('menu-bar'))!.toBeInTheDocument()
expect(screen.getByTestId('general-mode-content'))!.toBeInTheDocument()
expect(screen.getByTestId('pagination'))!.toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
expect(screen.getByTestId('drawer-group'))!.toBeInTheDocument()
})
@ -1103,17 +1077,24 @@ describe('Inline callback and hook initialization coverage', () => {
// Covers lines 56-58: useSearchFilter({ onPageChange: setCurrentPage })
it('should reset current page when status filter changes', async () => {
mockSegmentListData.total = 30
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
fireEvent.click(screen.getByTestId('next-page'))
fireEvent.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await waitFor(() => {
expect(screen.getByTestId('current-page'))!.toHaveTextContent('1')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'2',
)
})
fireEvent.click(screen.getByTestId('status-enabled'))
await waitFor(() => {
expect(screen.getByTestId('current-page'))!.toHaveTextContent('0')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'1',
)
})
})
@ -1228,16 +1209,23 @@ describe('Inline callback and hook initialization coverage', () => {
// Covers line 133-135: handlePageChange
it('should handle multiple page changes', async () => {
mockSegmentListData.total = 30
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
fireEvent.click(screen.getByTestId('next-page'))
fireEvent.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await waitFor(() => {
expect(screen.getByTestId('current-page'))!.toHaveTextContent('1')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'2',
)
})
fireEvent.click(screen.getByTestId('next-page'))
fireEvent.click(screen.getByRole('button', { name: 'common.pagination.next' }))
await waitFor(() => {
expect(screen.getByTestId('current-page'))!.toHaveTextContent('2')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-page',
'3',
)
})
})
@ -1248,7 +1236,10 @@ describe('Inline callback and hook initialization coverage', () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
expect(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute(
'data-totalpages',
'5',
)
})
// Covers search input change

View File

@ -1,25 +1,27 @@
import { Popover } from '@langgenius/dify-ui/popover'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Card from '../card'
vi.mock('@langgenius/dify-ui/popover', () => ({
PopoverClose: ({ render }: { render: React.ReactNode }) => render,
}))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
}))
describe('Service API card', () => {
it('shows the service endpoint and API reference', () => {
const renderCard = (props: React.ComponentProps<typeof Card>) =>
render(
<Card
apiBaseUrl="https://api.example.com"
canManageSecretKey
onOpenSecretKeyModal={vi.fn()}
/>,
<Popover>
<Card {...props} />
</Popover>,
)
it('shows the service endpoint and API reference', () => {
renderCard({
apiBaseUrl: 'https://api.example.com',
canManageSecretKey: true,
onOpenSecretKeyModal: vi.fn(),
})
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'dataset.serviceApi.card.apiReference' }),
@ -29,13 +31,11 @@ describe('Service API card', () => {
it('opens secret-key management when allowed', async () => {
const user = userEvent.setup()
const onOpenSecretKeyModal = vi.fn()
render(
<Card
apiBaseUrl="https://api.example.com"
canManageSecretKey
onOpenSecretKeyModal={onOpenSecretKeyModal}
/>,
)
renderCard({
apiBaseUrl: 'https://api.example.com',
canManageSecretKey: true,
onOpenSecretKeyModal,
})
await user.click(screen.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
@ -43,7 +43,7 @@ describe('Service API card', () => {
})
it('disables secret-key management when it is not allowed', () => {
render(<Card apiBaseUrl="https://api.example.com" onOpenSecretKeyModal={vi.fn()} />)
renderCard({ apiBaseUrl: 'https://api.example.com', onOpenSecretKeyModal: vi.fn() })
expect(screen.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' })).toBeDisabled()
})

View File

@ -9,29 +9,6 @@ import { render } from '@/test/console/render'
import { RETRIEVE_METHOD } from '@/types/app'
import HitTestingPage from '../index'
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({
page,
totalPages,
onPageChange,
labels,
}: {
page: number
totalPages: number
onPageChange: (page: number) => void
labels: { next: string }
}) => (
<button
type="button"
aria-label={labels.next}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{page}/{totalPages}
</button>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({
value,

View File

@ -10,22 +10,6 @@ vi.mock('@/app/components/base/action-button', () => ({
),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
variant,
}: {
children: React.ReactNode
onClick: () => void
variant?: string
}) => (
<button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('../../external-knowledge-base/create/RetrievalSettings', () => ({
default: ({
topK,
@ -89,13 +73,13 @@ describe('ModifyExternalRetrievalModal', () => {
it('should call onClose when cancel button clicked', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('cancel-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should call onSave with current values and close when save clicked', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('save-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(defaultProps.onSave).toHaveBeenCalledWith({
top_k: 4,
score_threshold: 0.5,
@ -107,14 +91,14 @@ describe('ModifyExternalRetrievalModal', () => {
it('should save updated values after settings change', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-top-k'))
fireEvent.click(screen.getByTestId('save-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(defaultProps.onSave).toHaveBeenCalledWith(expect.objectContaining({ top_k: 10 }))
})
it('should save updated score threshold', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-score'))
fireEvent.click(screen.getByTestId('save-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(defaultProps.onSave).toHaveBeenCalledWith(
expect.objectContaining({ score_threshold: 0.9 }),
)
@ -123,7 +107,7 @@ describe('ModifyExternalRetrievalModal', () => {
it('should save updated score threshold enabled', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-enabled'))
fireEvent.click(screen.getByTestId('save-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(defaultProps.onSave).toHaveBeenCalledWith(
expect.objectContaining({ score_threshold_enabled: true }),
)
@ -133,7 +117,7 @@ describe('ModifyExternalRetrievalModal', () => {
render(<ModifyExternalRetrievalModal {...defaultProps} />)
fireEvent.click(screen.getByTestId('change-top-k'))
fireEvent.click(screen.getByTestId('change-score'))
fireEvent.click(screen.getByTestId('save-button'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(defaultProps.onSave).toHaveBeenCalledWith(
expect.objectContaining({ top_k: 10, score_threshold: 0.9 }),
)

View File

@ -22,17 +22,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
variant?: string
}) => <button onClick={onClick}>{children}</button>,
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: vi.fn(() => true),
}))

View File

@ -1,49 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import DatasetListHeader from '../header'
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" className={className} {...props}>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({
children,
popupProps,
positionerProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => (
<div {...positionerProps}>
<div role="menu" {...popupProps}>
{children}
</div>
</div>
),
DropdownMenuItem: ({
children,
className,
onClick,
}: {
children: React.ReactNode
className?: string
onClick?: () => void
}) => (
<button type="button" role="menuitem" className={className} onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => render,
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: () => <div />,
}))
@ -79,21 +38,26 @@ describe('DatasetListHeader', () => {
vi.clearAllMocks()
})
it('uses the updated create menu labels and pipeline icon', () => {
it('shows dataset and pipeline creation actions in the create menu', async () => {
const user = userEvent.setup()
render(<DatasetListHeader {...defaultProps} />)
await user.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
expect(
screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ }),
).toBeInTheDocument()
const menuItem = screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ })
expect(menuItem.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
expect(
screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ }),
).toBeInTheDocument()
})
it('hides dataset creation actions without create permission', () => {
it('only shows external dataset connection without create permission', async () => {
const user = userEvent.setup()
render(<DatasetListHeader {...defaultProps} canCreateDataset={false} />)
await user.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
expect(
screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ }),
).not.toBeInTheDocument()

View File

@ -2,91 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ItemOperation from '../index'
vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
const DropdownMenuContext = React.createContext<{
isOpen: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({ children, modal }: { children: React.ReactNode; modal?: boolean }) => {
const [isOpen, setIsOpen] = React.useState(false)
return (
<DropdownMenuContext value={{ isOpen, setOpen: setIsOpen }}>
<div data-modal={modal} data-open={isOpen} data-testid="dropdown-menu">
{children}
</div>
</DropdownMenuContext>
)
},
DropdownMenuTrigger: ({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isOpen, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
onClick={(e) => {
onClick?.(e)
setOpen(!isOpen)
}}
{...props}
>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
popupProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen) return null
return (
<div data-testid="dropdown-content" {...popupProps}>
{children}
</div>
)
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('ItemOperation', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -179,12 +94,6 @@ describe('ItemOperation', () => {
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
})
it('should render a non-modal menu', () => {
renderComponent()
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
})
it('should stop propagation when clicking menu actions', async () => {
const onParentClick = vi.fn()
const togglePin = vi.fn()

View File

@ -1,42 +0,0 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import MenuDialog from '../menu-dialog'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}))
describe('MenuDialog dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should only call onClose when the dialog requests closing', () => {
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -48,10 +48,6 @@ vi.mock('react-i18next', async () => {
}
})
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: () => <div data-testid="time-picker" />,
}))
vi.mock(
'@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker',
() => ({

View File

@ -50,8 +50,6 @@ vi.mock('@tanstack/react-query', () => ({
})),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
@ -93,18 +91,12 @@ describe('ApiBasedExtensionSelector', () => {
it('should open dropdown when clicked', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
const popoverTrigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByText('common.apiBasedExtension.selector.placeholder')
const arrow = trigger.parentElement?.querySelector('[aria-hidden="true"]')
expect(popoverTrigger).not.toHaveAttribute('data-popup-open')
expect(arrow).toHaveClass('opacity-60')
fireEvent.click(trigger)
// Assert
// Assert
expect(popoverTrigger).toHaveAttribute('data-popup-open', '')
expect(arrow).not.toHaveClass('opacity-60')
expect(
await screen.findByText('common.apiBasedExtension.selector.title'),
)!.toBeInTheDocument()

View File

@ -1,4 +1,3 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import type { DataSourceAuth } from '../types'
import type { FormSchema } from '@/app/components/base/form/types'
import type {
@ -11,16 +10,6 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import Configure from '../configure'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button {...props}>{children}</button>
),
}))
/**
* Configure Component Tests
* Using Unit approach to ensure 100% coverage and stable tests.

View File

@ -26,6 +26,10 @@ vi.mock('@/app/components/plugins/readme-panel/store', () => ({
}),
}))
vi.mock('@/service/use-plugins', () => ({
useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () => ({
useDetailHeaderState: () => ({
modalStates: {
@ -60,24 +64,6 @@ vi.mock('@/app/components/base/badge', () => ({
default: ({ text }: { text: ReactNode }) => <div data-testid="badge">{text}</div>,
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
__esModule: true,
default: ({ trigger }: { trigger: ReactNode }) => <div>{trigger}</div>,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
@ -90,6 +89,10 @@ const saveUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
}
const openUpdateSettings = () => {
fireEvent.click(screen.getByRole('button', { name: /plugin\.autoUpdate\.autoUpdate/ }))
}
const createPluginDeclaration = (
overrides: Partial<PluginDeclaration> = {},
): PluginDeclaration => ({
@ -279,66 +282,11 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTrigger: ({ render }: { render: ReactNode }) => render,
DialogContent: ({ children }: { children: ReactNode }) => (
<div data-testid="update-setting-dialog">{children}</div>
),
DialogTitle: () => null,
DialogCloseButton: () => <button type="button" aria-label="close" />,
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: ({
value,
onChange,
renderTrigger,
}: {
value?: string | { format: (format: string) => string }
onChange: (value: { hour: () => number; minute: () => number }) => void
renderTrigger: (
props: Record<string, never>,
state: { open: boolean },
params: {
inputElem: ReactNode
onClick: () => void
},
) => ReactNode
}) => {
const displayValue = typeof value === 'string' ? value : value?.format('HH:mm')
return (
<div data-testid="update-time-picker">
{renderTrigger(
{},
{ open: false },
{
inputElem: <span data-testid="update-time-value">{displayValue}</span>,
onClick: vi.fn(),
},
)}
<button
type="button"
onClick={() =>
onChange({
hour: () => 1,
minute: () => 15,
})
}
>
set update time
</button>
</div>
)
},
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const originalWorkspaces = actual.consoleQuery.workspaces
@ -485,13 +433,12 @@ describe('ModelProviderPage', () => {
renderModelProviderPage()
expect(screen.getAllByText('plugin.autoUpdate.strategy.latest.name')[0]).toBeInTheDocument()
expect(screen.getAllByTestId('update-setting-dialog')[0]).toBeInTheDocument()
openUpdateSettings()
expect(
screen.getByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' }),
).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.scope')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument()
expect(screen.getByTestId('update-time-picker')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.changeTimezone')).toBeInTheDocument()
expect(
screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.fixOnly.name' }),
@ -506,10 +453,10 @@ describe('ModelProviderPage', () => {
const updateSettingButton = screen.getByText('plugin.autoUpdate.autoUpdate').closest('button')
expect(updateSettingButton).not.toBeDisabled()
expect(screen.queryByText('plugin.autoUpdate.strategy.latest.name')).not.toBeInTheDocument()
openUpdateSettings()
expect(screen.getByRole('status')).toHaveTextContent('common.loading')
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
expect(mockSaveAutoUpgrade).not.toHaveBeenCalled()
expect(screen.getByTestId('update-setting-dialog')).toBeInTheDocument()
})
it('should render a failure state when backend auto-upgrade data fails', () => {
@ -518,6 +465,7 @@ describe('ModelProviderPage', () => {
renderModelProviderPage()
openUpdateSettings()
expect(screen.getByText('common.api.actionFailed')).toBeInTheDocument()
expect(
screen.queryByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' }),
@ -529,6 +477,7 @@ describe('ModelProviderPage', () => {
it('should update scope from the dialog while keeping the backend returned strategy', () => {
renderModelProviderPage()
openUpdateSettings()
fireEvent.click(screen.getByRole('radio', { name: 'plugin.autoUpdate.upgradeMode.partial' }))
saveUpdateSettings()
@ -544,11 +493,13 @@ describe('ModelProviderPage', () => {
it('should update time from the popover while keeping the model provider default strategy as latest', () => {
renderModelProviderPage()
expect(screen.getByTestId('update-time-value')).toHaveTextContent('00:00')
openUpdateSettings()
fireEvent.click(screen.getByDisplayValue('12:00 AM'))
fireEvent.click(screen.getByRole('button', { name: '01' }))
fireEvent.click(screen.getByRole('button', { name: '15' }))
fireEvent.click(screen.getByRole('button', { name: 'time.operation.ok' }))
fireEvent.click(screen.getByRole('button', { name: 'set update time' }))
expect(screen.getByTestId('update-time-value')).toHaveTextContent('01:15')
expect(screen.getByDisplayValue('01:15 AM')).toBeInTheDocument()
saveUpdateSettings()

View File

@ -1,5 +1,6 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render } from '@/test/console/render'
import AddCustomModel from '../add-custom-model'
@ -51,17 +52,13 @@ vi.mock('@remixicon/react', () => ({
RiAddLine: () => <div data-testid="add-line-icon" />,
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-mock">{children}</div>
),
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('AddCustomModel', () => {
const getAddModelTrigger = () =>
screen
.getAllByRole('button', { name: /modelProvider.addModel/i })
.find((element) => element.getAttribute('aria-haspopup') === 'dialog') ??
screen.getAllByRole('button', { name: /modelProvider.addModel/i })[0]!
const mockProvider = {
provider: 'openai',
allow_custom_token: true,
@ -97,7 +94,8 @@ describe('AddCustomModel', () => {
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show models list when models are available', () => {
it('should show models list when models are available', async () => {
const user = userEvent.setup()
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
@ -106,19 +104,14 @@ describe('AddCustomModel', () => {
/>,
)
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.click(getAddModelTrigger())
fireEvent.click(trigger)
// The portal should be "open"
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(await screen.findByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should call handleOpenModalForAddCustomModelToModelList when clicking a model', () => {
it('should call handleOpenModalForAddCustomModelToModelList when clicking a model', async () => {
const user = userEvent.setup()
const model = { model: 'gpt-4', model_type: 'llm' }
mockCanAddedModels = [model]
render(
@ -128,13 +121,14 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByText('gpt-4'))
await user.click(getAddModelTrigger())
await user.click(await screen.findByText('gpt-4'))
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
})
it('should show existing model rows as disabled for create-only users', () => {
it('should show existing model rows as disabled for create-only users', async () => {
const user = userEvent.setup()
const model = { model: 'gpt-4', model_type: 'llm' }
mockWorkspacePermissionKeys.value = ['credential.create']
mockCanAddedModels = [model]
@ -146,20 +140,20 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
await user.click(getAddModelTrigger())
const modelRow = screen.getByText('gpt-4').closest('[aria-disabled]')
const modelRow = (await screen.findByText('gpt-4')).closest('[aria-disabled]')
expect(modelRow).toHaveAttribute('aria-disabled', 'true')
expect(modelRow).toHaveClass('cursor-not-allowed')
fireEvent.click(screen.getByText('gpt-4'))
await user.click(modelRow!)
expect(mockHandleOpenModalForAddCustomModelToModelList).not.toHaveBeenCalled()
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
await user.click(screen.getByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => {
it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', async () => {
const user = userEvent.setup()
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
@ -168,13 +162,14 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
await user.click(getAddModelTrigger())
await user.click(await screen.findByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show tooltip when no models and custom tokens not allowed', () => {
it('should show tooltip when no models and custom tokens not allowed', async () => {
const user = userEvent.setup()
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
mockCanAddedModels = []
render(
@ -184,10 +179,11 @@ describe('AddCustomModel', () => {
/>,
)
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
const trigger = getAddModelTrigger()
await user.hover(trigger)
expect(await screen.findByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
await user.click(trigger)
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
})
})

View File

@ -72,8 +72,6 @@ vi.mock('../authorized-item', () => ({
),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('Authorized', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
@ -102,6 +100,8 @@ describe('Authorized', () => {
</button>
)
const getTrigger = () => screen.getAllByRole('button', { name: /trigger\s*(open|closed)/i })[0]!
beforeEach(() => {
vi.clearAllMocks()
mockDeleteCredentialId = null
@ -119,13 +119,13 @@ describe('Authorized', () => {
/>,
)
const trigger = screen.getByTestId('popover-trigger')
const trigger = getTrigger()
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByRole('button', { name: /trigger\s*open/i })).toBeInTheDocument()
expect(trigger).toHaveTextContent(/trigger\s*open/i)
expect(screen.getByTestId('authorized-item'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /addApiKey/i }))!.toBeInTheDocument()
})
@ -141,9 +141,8 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
expect(mockHandleOpenModal).toHaveBeenCalled()
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should call onItemClick when credential is selected', () => {
@ -158,7 +157,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0]!.model)
@ -174,7 +173,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0]!.model)
@ -195,7 +194,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText(/addModelCredential/))
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
@ -215,7 +214,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
})
@ -231,7 +230,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]!)
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]!)
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
@ -254,7 +253,7 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]!)
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]!)
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
@ -278,11 +277,11 @@ describe('Authorized', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]!)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]!)
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
fireEvent.click(getTrigger())
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0]!)
expect(mockHandleOpenModal).toHaveBeenCalledWith(mockCredentials[0], mockItems[0]!.model)

View File

@ -1,23 +1,10 @@
import type { ReactNode } from 'react'
import type { Credential, ModelProvider } from '../../declarations'
import { act, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../../declarations'
import ModelModal from '../index'
type DialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let mockLanguage = 'en_US'
let latestDialogOnOpenChange: DialogProps['onOpenChange']
let latestAlertDialogOnOpenChange: AlertDialogProps['onOpenChange']
let mockAvailableCredentials: Credential[] | undefined = []
let mockDeleteCredentialId: string | null = null
@ -41,39 +28,6 @@ vi.mock('../../model-auth', () => ({
),
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button type="button">close</button>,
}))
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestAlertDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
AlertDialogConfirmButton: ({
children,
onClick,
}: {
children: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: false,
@ -153,53 +107,10 @@ describe('ModelModal dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
latestDialogOnOpenChange = undefined
latestAlertDialogOnOpenChange = undefined
mockAvailableCredentials = []
mockDeleteCredentialId = null
})
it('should only cancel when the dialog reports it has closed', () => {
const onCancel = vi.fn()
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={onCancel}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestDialogOnOpenChange?.(true)
latestDialogOnOpenChange?.(false)
})
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should only close the confirm dialog when the alert dialog closes', () => {
mockDeleteCredentialId = 'cred-1'
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestAlertDialogOnOpenChange?.(true)
latestAlertDialogOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should pass an empty credential list to the selector when no credentials are available', () => {
mockAvailableCredentials = undefined

View File

@ -1,45 +1,14 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ParameterItem from '../parameter-item'
vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
return {
...actual,
Select: ({
children,
onValueChange,
}: {
children: ReactNode
onValueChange: (value: string | undefined) => void
}) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>
select-updated
</button>
<button type="button" onClick={() => onValueChange(undefined)}>
select-empty
</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectLabel: () => null,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {
it('should propagate a selected value', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
@ -57,10 +26,9 @@ describe('ParameterItem select mode', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-updated' }))
fireEvent.click(screen.getByRole('button', { name: 'select-empty' }))
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'text' }))
expect(onChange).toHaveBeenNthCalledWith(1, 'updated')
expect(onChange).toHaveBeenNthCalledWith(2, undefined)
expect(onChange).toHaveBeenCalledWith('text')
})
})

View File

@ -8,14 +8,6 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@langgenius/dify-ui/slider', () => ({
Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => (
<button onClick={() => onValueChange(2)} data-testid="slider-btn">
Slide 2
</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (v: string[]) => void }) => (
<button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input">
@ -83,7 +75,7 @@ describe('ParameterItem', () => {
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '1.4' } })
expect(onChange).toHaveBeenCalledWith(1)
expect(screen.getByTestId('slider-btn'))!.toBeInTheDocument()
expect(screen.getByRole('slider'))!.toBeInTheDocument()
})
it('should clamp float numeric input to min', () => {
@ -139,20 +131,6 @@ describe('ParameterItem', () => {
expect(screen.getByRole('spinbutton'))!.toHaveAttribute('step', '0')
})
it('should handle slide change and clamp values', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type: 'float', min: 0, max: 10 })}
value={0.7}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByTestId('slider-btn'))
expect(onChange).toHaveBeenCalledWith(2)
})
it('should render exact string input and propagate text changes', () => {
const onChange = vi.fn()
render(

View File

@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, screen } from '@testing-library/react'
import { PluginSource } from '@/app/components/plugins/types'
@ -48,6 +48,10 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () =
}),
}))
vi.mock('@/service/use-plugins', () => ({
useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({
HeaderModals: ({
targetVersion,
@ -77,29 +81,6 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
}),
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
default: ({
trigger,
onSelect,
disabled,
}: {
trigger: (open: boolean) => ReactNode
onSelect: (state: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void
disabled?: boolean
}) => (
<div data-testid="plugin-version-picker" data-disabled={String(Boolean(disabled))}>
{trigger(false)}
<button
type="button"
onClick={() =>
onSelect({ version: '2.0.0', unique_identifier: 'plugin@2.0.0', isDowngrade: true })
}
>
select version
</button>
</div>
),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
@ -150,20 +131,10 @@ describe('ProviderCardActions', () => {
)
})
it('should render version controls for marketplace plugins and handle manual version selection', () => {
it('should render version controls for marketplace plugins', () => {
render(<ProviderCardActions detail={createDetail()} />)
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'false')
fireEvent.click(screen.getByRole('button', { name: 'select version' }))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'plugin@2.0.0',
isDowngrade: true,
})
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
expect(screen.getByRole('button', { name: '1.0.0' })).toBeEnabled()
})
it('should show a compact debug badge after the version for debugging plugins', () => {
@ -232,7 +203,7 @@ describe('ProviderCardActions', () => {
/>,
)
expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'true')
expect(screen.getByRole('button', { name: '1.0.0' })).toBeDisabled()
openActionsMenu()
expect(
screen.getByRole('menuitem', { name: 'plugin.detailPanel.operation.viewDetail' }),

View File

@ -1,16 +1,9 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../../../declarations'
import type { CredentialPanelState } from '../../use-credential-panel-state'
import { act, fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import DropdownContent from '../dropdown-content'
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: AlertDialogProps['onOpenChange']
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
@ -42,44 +35,10 @@ vi.mock('../use-activate-credential', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
AlertDialogConfirmButton: ({
children,
onClick,
}: {
children: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: () => <div />,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../api-key-section', () => ({
default: ({
credentials,
onDelete,
}: {
credentials: unknown[]
onDelete: (credential?: unknown) => void
}) => (
default: ({ credentials }: { credentials: unknown[] }) => (
<div>
<span>{`credentials:${credentials.length}`}</span>
<button type="button" onClick={() => onDelete(undefined)}>
delete-undefined
</button>
</div>
),
}))
@ -135,7 +94,6 @@ vi.mock('@/context/permission-state', async () => {
describe('DropdownContent dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should fall back to an empty credential list when the provider has no credentials', () => {
@ -151,39 +109,4 @@ describe('DropdownContent dialog branches', () => {
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should ignore delete requests without a credential payload', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' }))
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
})
it('should only close the confirm dialog when the alert dialog reports closed', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
act(() => {
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,6 +1,7 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import { ToastHost } from '@langgenius/dify-ui/toast'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { languages } from '@/i18n-config/language'
import { updateUserProfile } from '@/service/common'
import { render } from '@/test/console/render'
@ -12,72 +13,6 @@ const mockMutateUserProfile = vi.fn()
let mockLocale: string | undefined = 'en-US'
let mockUserProfile: GetAccountProfileResponse
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
disabled?: boolean
onValueChange?: (value: string) => void
}>({})
return {
Select: ({
children,
disabled,
onValueChange,
}: {
children: React.ReactNode
disabled?: boolean
onValueChange?: (value: string) => void
}) => {
return (
<SelectContext.Provider value={{ disabled, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
)
},
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button" disabled={context.disabled}>
{children}
</button>
<button
data-testid="select-empty"
type="button"
onClick={() => context.onValueChange?.('')}
>
empty value
</button>
<button
data-testid="select-invalid"
type="button"
onClick={() => context.onValueChange?.('__missing__')}
>
invalid value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
return (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => context.onValueChange?.(value)}
>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))
@ -135,13 +70,10 @@ const getSectionByLabel = (sectionLabel: string) => {
}
const selectOption = async (sectionLabel: string, optionName: string) => {
const user = userEvent.setup()
const section = getSectionByLabel(sectionLabel)
await act(async () => {
fireEvent.click(within(section).getAllByRole('button')[0]!)
})
await act(async () => {
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
})
await user.click(within(section).getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: optionName }))
}
const getLanguageOption = (value: string) => {
@ -178,8 +110,12 @@ describe('PreferencePage - Rendering', () => {
expect(screen.getByText('common.language.displayLanguage')).toBeInTheDocument()
expect(screen.getByText('common.language.timezone')).toBeInTheDocument()
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
expect(
within(getSectionByLabel('common.language.displayLanguage')).getByRole('combobox'),
).toHaveTextContent(english.name)
expect(
within(getSectionByLabel('common.language.timezone')).getByRole('combobox'),
).toHaveTextContent(niueTimezone.name)
})
it('should render placeholders when the current locale or timezone is unsupported', () => {
@ -191,7 +127,12 @@ describe('PreferencePage - Rendering', () => {
renderPage()
expect(screen.getAllByRole('button', { name: 'common.placeholder.select' })).toHaveLength(2)
expect(
within(getSectionByLabel('common.language.displayLanguage')).getByRole('combobox'),
).toHaveTextContent('common.placeholder.select')
expect(
within(getSectionByLabel('common.language.timezone')).getByRole('combobox'),
).toHaveTextContent('common.placeholder.select')
})
})
@ -253,30 +194,4 @@ describe('PreferencePage - Interactions', () => {
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
}, 15000)
it('should ignore empty and unknown language selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.displayLanguage')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
it('should ignore empty and unknown timezone selections', async () => {
renderPage()
const section = getSectionByLabel('common.language.timezone')
await act(async () => {
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
})
expect(updateUserProfileMock).not.toHaveBeenCalled()
})
})

View File

@ -1,5 +1,6 @@
import type { PluginDeclaration, UpdateFromGitHubPayload } from '../../../../types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import SelectPackage from '../selectPackage'
@ -21,69 +22,6 @@ vi.mock('../../../hooks', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
readOnly?: boolean
onValueChange?: (value: string) => void
}>({})
return {
Select: ({
children,
readOnly,
onValueChange,
}: {
children: React.ReactNode
readOnly?: boolean
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ readOnly, onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<div
data-testid="select-trigger"
className={context.readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}
>
{children}
</div>
<button
data-testid="select-empty"
type="button"
onClick={() => context.onValueChange?.('')}
>
empty select value
</button>
<button
data-testid="select-invalid"
type="button"
onClick={() => context.onValueChange?.('__missing__')}
>
invalid select value
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
return (
<button type="button" onClick={() => context.onValueChange?.(value)}>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
plugin_unique_identifier: 'test-uid',
@ -312,28 +250,20 @@ describe('SelectPackage', () => {
expect(mockHandleUpload).not.toHaveBeenCalled()
})
it('should ignore empty and unknown version selections', () => {
it('should select a valid version option', async () => {
const user = userEvent.setup()
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectVersion).not.toHaveBeenCalled()
})
it('should select a valid version option', () => {
const onSelectVersion = vi.fn()
renderSelectPackage({ onSelectVersion })
const section = getSection('plugin.installFromGitHub.selectVersion')
fireEvent.click(within(section).getByRole('button', { name: 'v0.9.0' }))
await user.click(within(section).getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'v0.9.0' }))
expect(onSelectVersion).toHaveBeenCalledWith({ value: 'v0.9.0', name: 'v0.9.0' })
})
it('should ignore empty and unknown package selections', () => {
it('should select a valid package option', async () => {
const user = userEvent.setup()
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
@ -341,21 +271,8 @@ describe('SelectPackage', () => {
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByTestId('select-empty'))
fireEvent.click(within(section).getByTestId('select-invalid'))
expect(onSelectPackage).not.toHaveBeenCalled()
})
it('should select a valid package option', () => {
const onSelectPackage = vi.fn()
renderSelectPackage({
selectedVersion: 'v1.0.0',
onSelectPackage,
})
const section = getSection('plugin.installFromGitHub.selectPackage')
fireEvent.click(within(section).getByRole('button', { name: 'plugin.tar.gz' }))
await user.click(within(section).getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'plugin.tar.gz' }))
expect(onSelectPackage).toHaveBeenCalledWith({
value: 'plugin.tar.gz',
@ -563,16 +480,16 @@ describe('SelectPackage', () => {
renderSelectPackage({ selectedVersion: '' })
// When no version is selected, package select should be readonly
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-not-allowed')
const trigger = screen.getAllByRole('combobox')[1]
expect(trigger).toHaveAttribute('aria-readonly', 'true')
})
it('should make package select active when version is selected', () => {
renderSelectPackage({ selectedVersion: 'v1.0.0' })
// When version is selected, package select should be active
const trigger = screen.getAllByTestId('select-trigger')[1]
expect(trigger).toHaveClass('cursor-pointer')
const trigger = screen.getAllByRole('combobox')[1]
expect(trigger).not.toHaveAttribute('aria-readonly', 'true')
})
})

View File

@ -33,8 +33,6 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('TagsFilter', () => {
const ensurePopoverOpen = async (user: ReturnType<typeof userEvent.setup>) => {
if (!screen.queryByRole('searchbox', { name: 'pluginTags.searchTags' }))

View File

@ -1,4 +1,3 @@
import type { MouseEventHandler, ReactNode } from 'react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SortDropdown from '../index'
@ -33,78 +32,6 @@ vi.mock('../../atoms', () => ({
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div data-testid="dropdown-wrapper" data-open={open}>
{children}
</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({ children, className }: { children: ReactNode; className?: string }) => {
const { open, setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
data-testid="dropdown-trigger"
onClick={() => setOpen(!open)}
>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div data-testid="dropdown-content">{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: ReactNode
onClick?: MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('SortDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -114,7 +41,7 @@ describe('SortDropdown', () => {
it('renders the selected sort option in the trigger', () => {
render(<SortDropdown />)
const trigger = screen.getByTestId('dropdown-trigger')
const trigger = screen.getByRole('button', { name: 'Sort by Most Popular' })
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
})
@ -131,9 +58,9 @@ describe('SortDropdown', () => {
const user = userEvent.setup()
render(<SortDropdown />)
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' }))
const content = screen.getByTestId('dropdown-content')
const content = await screen.findByRole('menu')
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
@ -142,24 +69,24 @@ describe('SortDropdown', () => {
it('shows a check icon for the currently selected option', async () => {
const user = userEvent.setup()
const { container } = render(<SortDropdown />)
render(<SortDropdown />)
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' }))
expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument()
expect(document.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('updates the sort and closes the menu when an option is selected', async () => {
const user = userEvent.setup()
render(<SortDropdown />)
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByText('Recently Updated'))
await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' }))
await user.click(await screen.findByRole('menuitem', { name: 'Recently Updated' }))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'version_updated_at',
sortOrder: 'DESC',
})
expect(screen.queryByTestId('dropdown-content')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})

View File

@ -82,8 +82,6 @@ vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
const mockConsoleState = vi.hoisted(() => ({
userProfile: { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' },
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
@ -203,13 +201,10 @@ describe('Authorized Component', () => {
{ wrapper: createWrapper() },
)
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(screen.getByText('Closed'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('Open')).toBeInTheDocument()
})

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import AppInputsForm from '../app-inputs-form'
@ -26,59 +27,6 @@ vi.mock('@/app/components/base/file-uploader', () => ({
),
}))
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SelectContext)
return (
<div>
<button type="button">{children}</button>
<button
data-testid="select-empty"
type="button"
onClick={() => context.onValueChange?.('')}
>
Empty Select
</button>
</div>
)
},
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.useContext(SelectContext)
return (
<button
key={value}
data-testid={`select-${value}`}
type="button"
onClick={() => context.onValueChange?.(value)}
>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -146,7 +94,8 @@ describe('AppInputsForm', () => {
expect(onFormChange).toHaveBeenCalledWith({ count: '42' })
})
it('should update select values', () => {
it('should update select values', async () => {
const user = userEvent.setup()
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
@ -167,38 +116,12 @@ describe('AppInputsForm', () => {
/>,
)
fireEvent.click(screen.getByTestId('select-formal'))
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'formal' }))
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should ignore empty select values and render the placeholder when there is no current selection', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
render(
<AppInputsForm
inputsForms={[
{
variable: 'tone',
label: 'Tone',
type: InputVarType.select,
options: ['friendly', 'formal'],
required: false,
},
]}
inputs={{ tone: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
expect(screen.getAllByText('Tone').length).toBeGreaterThan(0)
fireEvent.click(screen.getByTestId('select-empty'))
expect(onFormChange).not.toHaveBeenCalled()
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }

View File

@ -36,6 +36,10 @@ vi.mock('@/service/use-tools', () => ({
}),
}))
vi.mock('@/service/use-plugins', () => ({
useVersionListOfPlugin: () => ({ data: { data: { versions: [] } } }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`,
}))
@ -48,24 +52,12 @@ vi.mock('@/app/components/base/action-button', () => ({
),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text, children }: { text?: React.ReactNode; children?: React.ReactNode }) => (
<div data-testid="badge">{text ?? children}</div>
),
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: {
tool: 'tool',
@ -75,28 +67,6 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
),
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
default: ({
onSelect,
trigger,
}: {
onSelect: (value: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void
trigger: React.ReactNode
}) => (
<div>
{trigger}
<button
data-testid="version-select"
onClick={() =>
onSelect({ version: '2.0.0', unique_identifier: 'uid-2', isDowngrade: true })
}
>
select version
</button>
</div>
),
}))
vi.mock('@/app/components/base/badges/verified', () => ({
default: () => <div data-testid="verified" />,
}))
@ -244,21 +214,14 @@ describe('DetailHeader', () => {
expect(screen.getByTestId('header-modals')).toBeInTheDocument()
})
it('wires version selection, latest update, and hide actions', () => {
it('wires latest update and hide actions', () => {
const onHide = vi.fn()
render(<DetailHeader detail={createDetail()} onHide={onHide} onUpdate={vi.fn()} />)
fireEvent.click(screen.getByTestId('version-select'))
fireEvent.click(screen.getByText('plugin.detailPanel.operation.update'))
fireEvent.click(screen.getByTestId('close-button'))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'uid-2',
isDowngrade: true,
})
expect(mockHandleUpdate).toHaveBeenCalledTimes(2)
expect(mockHandleUpdate).toHaveBeenNthCalledWith(1, true)
expect(mockHandleUpdate).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalled()
})
})

View File

@ -1,689 +1,73 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TTSParamsPanel from '../tts-params-panel'
// ==================== Mock Setup ====================
// All vi.mock() calls are hoisted, so inline all mock data
// Mock languages data with inline definition
vi.mock('@/i18n-config/language', () => ({
languages: [
{ value: 'en-US', name: 'English (United States)', supported: true },
{ value: 'zh-Hans', name: '简体中文', supported: true },
{ value: 'ja-JP', name: '日本語', supported: true },
{ value: 'unsupported-lang', name: 'Unsupported Language', supported: false },
{ value: 'en-US', name: 'English', supported: true },
{ value: 'zh-Hans', name: 'Chinese', supported: true },
{ value: 'unsupported', name: 'Unsupported', supported: false },
],
}))
const MockSelectContext = React.createContext<{
value: string
onValueChange: (value: string) => void
}>({
value: '',
onValueChange: () => {},
})
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
children: React.ReactNode
className?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button data-testid={`select-item-${value}`} onClick={() => onValueChange(value)}>
{children}
</button>
)
},
SelectItemText: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => <span data-class={className}>{children}</span>,
SelectItemIndicator: ({ className }: { className?: string }) => (
<span data-testid="select-item-indicator" data-class={className} />
),
}
})
// ==================== Test Utilities ====================
/**
* Factory function to create a voice item
*/
const createVoiceItem = (overrides: Partial<{ mode: string; name: string }> = {}) => ({
mode: 'alloy',
name: 'Alloy',
...overrides,
})
/**
* Factory function to create a currentModel with voices
*/
const createCurrentModel = (voices: Array<{ mode: string; name: string }> = []) => ({
const model = {
model_properties: {
voices,
voices: [
{ mode: 'alloy', name: 'Alloy' },
{ mode: 'echo', name: 'Echo' },
],
},
})
/**
* Factory function to create default props
*/
const createDefaultProps = (
overrides: Partial<{
currentModel: { model_properties: { voices: Array<{ mode: string; name: string }> } } | null
language: string
voice: string
onChange: (language: string, voice: string) => void
}> = {},
) => ({
currentModel: createCurrentModel([
createVoiceItem({ mode: 'alloy', name: 'Alloy' }),
createVoiceItem({ mode: 'echo', name: 'Echo' }),
createVoiceItem({ mode: 'fable', name: 'Fable' }),
]),
language: 'en-US',
voice: 'alloy',
onChange: vi.fn(),
...overrides,
})
// ==================== Tests ====================
}
describe('TTSParamsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
it('renders the selected language and voice', () => {
render(
<TTSParamsPanel currentModel={model} language="en-US" voice="alloy" onChange={vi.fn()} />,
)
expect(
screen.getByRole('combobox', { name: 'appDebug.voice.voiceSettings.language' }),
).toHaveTextContent('en-US')
expect(
screen.getByRole('combobox', { name: 'appDebug.voice.voiceSettings.voice' }),
).toHaveTextContent('alloy')
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render language label', () => {
// Arrange
const props = createDefaultProps()
it('only exposes supported languages and preserves the selected voice', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<TTSParamsPanel currentModel={model} language="en-US" voice="alloy" onChange={onChange} />,
)
// Act
render(<TTSParamsPanel {...props} />)
await user.click(
screen.getByRole('combobox', { name: 'appDebug.voice.voiceSettings.language' }),
)
const listbox = await screen.findByRole('listbox')
expect(within(listbox).queryByRole('option', { name: 'Unsupported' })).not.toBeInTheDocument()
await user.click(within(listbox).getByRole('option', { name: 'Chinese' }))
// Assert
// Assert
expect(screen.getByText('appDebug.voice.voiceSettings.language'))!.toBeInTheDocument()
})
it('should render voice label', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByText('appDebug.voice.voiceSettings.voice'))!.toBeInTheDocument()
})
it('should render two Select components', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('select-root')
expect(selects).toHaveLength(2)
})
it('should render language select with correct value', () => {
// Arrange
const props = createDefaultProps({ language: 'zh-Hans' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const values = screen.getAllByTestId('selected-value')
expect(values[0])!.toHaveTextContent('zh-Hans')
})
it('should render voice select with correct value', () => {
// Arrange
const props = createDefaultProps({ voice: 'echo' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const values = screen.getAllByTestId('selected-value')
expect(values[1])!.toHaveTextContent('echo')
})
it('should only show supported languages in language select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-en-US'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-zh-Hans'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-ja-JP'))!.toBeInTheDocument()
expect(screen.queryByTestId('select-item-unsupported-lang')).not.toBeInTheDocument()
})
it('should render voice items from currentModel', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-alloy'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-echo'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-fable'))!.toBeInTheDocument()
})
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'alloy')
})
// ==================== Props Testing ====================
it('changes the voice while preserving the selected language', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<TTSParamsPanel currentModel={model} language="en-US" voice="alloy" onChange={onChange} />,
)
// ==================== Event Handlers ====================
describe('Event Handlers', () => {
describe('setLanguage', () => {
it('should call onChange with new language and current voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'alloy',
})
await user.click(screen.getByRole('combobox', { name: 'appDebug.voice.voiceSettings.voice' }))
await user.click(await screen.findByRole('option', { name: 'Echo' }))
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'alloy')
})
it('should call onChange with different languages', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'echo',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-ja-JP'))
// Assert
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
})
it('should preserve voice when changing language', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'fable',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
})
})
describe('setVoice', () => {
it('should call onChange with current language and new voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledWith('en-US', 'echo')
})
it('should call onChange with different voices', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'zh-Hans',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-fable'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
})
it('should preserve language when changing voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'ja-JP',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
})
})
expect(onChange).toHaveBeenCalledWith('en-US', 'echo')
})
// ==================== Memoization ====================
describe('Memoization - voiceList', () => {
it('should return empty array when currentModel is null', () => {
// Arrange
const props = createDefaultProps({ currentModel: null })
it('renders an empty voice list without a model', async () => {
render(<TTSParamsPanel currentModel={null} language="en-US" voice="" onChange={vi.fn()} />)
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.voice.voiceSettings.voice' }))
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
// Assert - no voice items should be rendered
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
expect(screen.queryByTestId('select-item-echo')).not.toBeInTheDocument()
})
it('should return empty array when currentModel is undefined', () => {
// Arrange
const props = {
currentModel: undefined,
language: 'en-US',
voice: 'alloy',
onChange: vi.fn(),
}
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should map voices with mode as value', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([
{ mode: 'voice-1', name: 'Voice One' },
{ mode: 'voice-2', name: 'Voice Two' },
]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-voice-1'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-voice-2'))!.toBeInTheDocument()
})
it('should handle currentModel with empty voices array', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert - no voice items (except language items)
expect(screen.getAllByTestId('select-content')[1]!.children).toHaveLength(0)
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should handle currentModel with single voice', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([{ mode: 'single-voice', name: 'Single Voice' }]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-single-voice'))!.toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty language value', () => {
// Arrange
const props = createDefaultProps({ language: '' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const values = screen.getAllByTestId('selected-value')
expect(values[0])!.toHaveTextContent('')
})
it('should handle empty voice value', () => {
// Arrange
const props = createDefaultProps({ voice: '' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const values = screen.getAllByTestId('selected-value')
expect(values[1])!.toHaveTextContent('')
})
it('should handle many voices', () => {
// Arrange
const manyVoices = Array.from({ length: 20 }, (_, i) => ({
mode: `voice-${i}`,
name: `Voice ${i}`,
}))
const props = createDefaultProps({
currentModel: createCurrentModel(manyVoices),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-voice-0'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-voice-19'))!.toBeInTheDocument()
})
it('should handle voice with special characters in mode', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([
{ mode: 'voice-with_special.chars', name: 'Special Voice' },
]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-voice-with_special.chars'))!.toBeInTheDocument()
})
it('should handle onChange not being called multiple times', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({ onChange })
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledTimes(1)
})
})
// ==================== Re-render Behavior ====================
describe('Re-render Behavior', () => {
it('should update when language prop changes', () => {
// Arrange
const props = createDefaultProps({ language: 'en-US' })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const values = screen.getAllByTestId('selected-value')
expect(values[0])!.toHaveTextContent('en-US')
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
// Assert
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[0])!.toHaveTextContent('zh-Hans')
})
it('should update when voice prop changes', () => {
// Arrange
const props = createDefaultProps({ voice: 'alloy' })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const values = screen.getAllByTestId('selected-value')
expect(values[1])!.toHaveTextContent('alloy')
rerender(<TTSParamsPanel {...props} voice="echo" />)
// Assert
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[1])!.toHaveTextContent('echo')
})
it('should update voice list when currentModel changes', () => {
// Arrange
const initialModel = createCurrentModel([{ mode: 'alloy', name: 'Alloy' }])
const props = createDefaultProps({ currentModel: initialModel })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
expect(screen.getByTestId('select-item-alloy'))!.toBeInTheDocument()
expect(screen.queryByTestId('select-item-nova')).not.toBeInTheDocument()
const newModel = createCurrentModel([
{ mode: 'alloy', name: 'Alloy' },
{ mode: 'nova', name: 'Nova' },
])
rerender(<TTSParamsPanel {...props} currentModel={newModel} />)
// Assert
// Assert
expect(screen.getByTestId('select-item-alloy'))!.toBeInTheDocument()
expect(screen.getByTestId('select-item-nova'))!.toBeInTheDocument()
})
it('should handle currentModel becoming null', () => {
// Arrange
const props = createDefaultProps()
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
expect(screen.getByTestId('select-item-alloy'))!.toBeInTheDocument()
rerender(<TTSParamsPanel {...props} currentModel={null} />)
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
// Assert
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
})
// ==================== Accessibility ====================
describe('Accessibility', () => {
it('should have proper label structure for language select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const languageLabel = screen.getByText('appDebug.voice.voiceSettings.language')
expect(languageLabel)!.toHaveClass('system-sm-semibold')
})
it('should have proper label structure for voice select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const voiceLabel = screen.getByText('appDebug.voice.voiceSettings.voice')
expect(voiceLabel)!.toHaveClass('system-sm-semibold')
})
expect(await screen.findByRole('listbox')).toBeEmptyDOMElement()
})
})

View File

@ -1,72 +1,10 @@
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerCredentialType } from '@/app/components/workflow/block-selector/types'
import { SubscriptionSelectorEntry } from '../selector-entry'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider>
}
type TriggerProps = React.HTMLAttributes<HTMLElement> & {
'data-popup-open'?: string
'data-testid'?: string
}
const PopoverTrigger = ({
render,
}: {
render:
| React.ReactNode
| ((props: TriggerProps, state: { open: boolean }) => React.ReactElement)
}) => {
const { open, setOpen } = React.useContext(PopoverContext)
const props: TriggerProps = {
'data-testid': 'popover-trigger',
'data-popup-open': open ? '' : undefined,
onClick: () => setOpen(!open),
}
if (typeof render === 'function') return render(props, { open })
return <div {...props}>{render}</div>
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
return open ? <div data-testid="popover-content">{children}</div> : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
let mockSubscriptions: TriggerSubscription[] = []
const mockRefetch = vi.fn()
@ -128,16 +66,15 @@ describe('SubscriptionSelectorEntry', () => {
).toBeInTheDocument()
})
it('should render placeholder when open without selection', () => {
it('should render placeholder when open without selection', async () => {
const user = userEvent.setup()
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button'))
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('pluginTrigger.subscription.selectPlaceholder')).toBeInTheDocument()
expect(
await screen.findByText('pluginTrigger.subscription.selectPlaceholder'),
).toBeInTheDocument()
})
it('should show selected subscription name when id matches', () => {

View File

@ -2,66 +2,13 @@ import type { ReactNode } from 'react'
import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import ReasoningConfigForm from '../reasoning-config-form'
vi.mock('@langgenius/dify-ui/select', async () => {
const React = await import('react')
const SelectContext = React.createContext<{
onValueChange?: (value: string) => void
}>({})
return {
Select: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange?: (value: string) => void
}) => (
<SelectContext value={{ onValueChange }}>
<div>{children}</div>
</SelectContext>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => {
const context = React.use(SelectContext)
return (
<button
key={value}
data-testid={`select-${value}`}
type="button"
onClick={() => context.onValueChange?.(value)}
>
{children}
</button>
)
},
SelectItemText: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItemIndicator: () => null,
}
})
vi.mock('@langgenius/dify-ui/switch', () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean
onCheckedChange: (checked: boolean) => void
}) => (
<button data-testid="auto-switch" onClick={() => onCheckedChange(!checked)}>
{checked ? 'on' : 'off'}
</button>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
@ -211,7 +158,7 @@ describe('ReasoningConfigForm', () => {
/>,
)
fireEvent.click(screen.getByTestId('auto-switch'))
fireEvent.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith({
field: {
@ -407,7 +354,8 @@ describe('ReasoningConfigForm', () => {
})
})
it('should update number, boolean, and select fields', () => {
it('should update number, boolean, and select fields', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
@ -469,7 +417,8 @@ describe('ReasoningConfigForm', () => {
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
fireEvent.click(screen.getByTestId('boolean-input'))
fireEvent.click(screen.getByTestId('select-beta'))
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'Beta' }))
expect(onChange).toHaveBeenNthCalledWith(
1,

View File

@ -2,15 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SchemaModal } from '../schema-modal'
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock(
'@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor',
() => ({

View File

@ -1,5 +1,6 @@
import type { MetaData, PluginCategoryEnum } from '../../types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { expectLoadingButton } from '@/test/button'
// ==================== Imports (after mocks) ====================
@ -94,8 +95,6 @@ vi.mock('../../plugin-page/plugin-info', () => ({
),
}))
vi.mock('@langgenius/dify-ui/tooltip', () => import('@/__mocks__/base-ui-tooltip'))
// ==================== Test Utilities ====================
type ActionProps = {
@ -229,7 +228,8 @@ describe('Action Component', () => {
expect(queryActionButtons()).toHaveLength(0)
})
it('should render tooltips for each button', () => {
it('should render tooltips for each button', async () => {
const user = userEvent.setup()
// Arrange
const props = createActionProps({
isShowFetchNewVersion: true,
@ -242,16 +242,16 @@ describe('Action Component', () => {
// Assert
const buttons = getActionButtons()
fireEvent.mouseEnter(buttons[0]!)
expect(screen.getByText('plugin.action.checkForUpdates'))!.toBeInTheDocument()
fireEvent.mouseLeave(buttons[0]!)
await user.hover(buttons[0]!)
expect(await screen.findByText('plugin.action.checkForUpdates'))!.toBeInTheDocument()
await user.unhover(buttons[0]!)
fireEvent.mouseEnter(buttons[1]!)
expect(screen.getByText('plugin.action.pluginInfo'))!.toBeInTheDocument()
fireEvent.mouseLeave(buttons[1]!)
await user.hover(buttons[1]!)
expect(await screen.findByText('plugin.action.pluginInfo'))!.toBeInTheDocument()
await user.unhover(buttons[1]!)
fireEvent.mouseEnter(buttons[2]!)
expect(screen.getByText('plugin.action.delete'))!.toBeInTheDocument()
await user.hover(buttons[2]!)
expect(await screen.findByText('plugin.action.delete'))!.toBeInTheDocument()
})
})

View File

@ -45,149 +45,6 @@ vi.mock('@remixicon/react', () => ({
),
}))
type MockButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string
}
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, className, variant, ...props }: MockButtonProps) => (
<button
type="button"
data-testid="button-content"
data-variant={variant}
className={className}
onClick={onClick}
{...props}
>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{
isOpen: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
open,
onOpenChange,
modal,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean
children: React.ReactNode
}) => {
const [internalOpen, setInternalOpen] = React.useState(open ?? false)
const isOpen = open ?? internalOpen
const setOpen = (nextOpen: boolean) => {
if (open === undefined) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<DropdownMenuContext value={{ isOpen, setOpen }}>
<div data-testid="dropdown-menu" data-open={isOpen} data-modal={modal}>
{children}
</div>
</DropdownMenuContext>
)
},
DropdownMenuTrigger: ({
children,
onClick,
render,
}: {
children?: React.ReactNode
onClick?: React.MouseEventHandler<HTMLElement>
render?:
| React.ReactElement
| ((
props: React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-popup-open'?: string
},
state: { open: boolean },
) => React.ReactElement)
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (typeof render === 'function')
return render(
{
'data-testid': 'dropdown-trigger',
'data-popup-open': isOpen ? '' : undefined,
onClick: handleClick,
},
{ open: isOpen },
)
if (render)
return React.cloneElement(
render,
{ 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>,
children,
)
return (
<button data-testid="dropdown-trigger" onClick={handleClick}>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => {
const { isOpen } = useDropdownMenuContext()
return isOpen ? (
<div data-testid="dropdown-content" className={popupClassName}>
{children}
</div>
) : null
},
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="github-modal">
@ -209,6 +66,8 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', ()
),
}))
const getTrigger = (name = 'plugin.installPlugin') => screen.getByRole('button', { name })
describe('InstallPluginDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -217,24 +76,23 @@ describe('InstallPluginDropdown', () => {
})
it('shows all install methods when marketplace and custom installs are enabled', () => {
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-modal', 'false')
expect(screen.getByText('plugin.installFrom')).toBeInTheDocument()
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
expect(screen.getByText('plugin.source.github')).toBeInTheDocument()
expect(screen.getByText('plugin.source.local')).toBeInTheDocument()
expect(container.querySelector('.i-custom-vender-plugin-box-sparkle-fill')).toHaveClass(
expect(document.querySelector('.i-custom-vender-plugin-box-sparkle-fill')).toHaveClass(
'size-4',
'shrink-0',
)
expect(container.querySelector('.i-custom-vender-solid-general-github')).toHaveClass(
expect(document.querySelector('.i-custom-vender-solid-general-github')).toHaveClass(
'size-4',
'shrink-0',
)
expect(container.querySelector('.i-custom-vender-solid-files-file-zip')).toHaveClass(
expect(document.querySelector('.i-custom-vender-solid-files-file-zip')).toHaveClass(
'size-4',
'shrink-0',
)
@ -253,21 +111,20 @@ describe('InstallPluginDropdown', () => {
/>,
)
const trigger = screen.getByTestId('dropdown-trigger')
const trigger = getTrigger('Install')
expect(container.querySelector('.custom-root')).toBeInTheDocument()
expect(trigger).toHaveTextContent('Install')
expect(screen.getByTestId('add-circle-fill-icon')).toHaveClass('size-4', 'shrink-0')
expect(screen.getByTestId('arrow-down-icon')).toHaveClass('ml-1', 'size-4')
expect(trigger).toHaveClass('custom-trigger')
expect(trigger).toHaveAttribute('data-variant', 'primary')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
expect(trigger).toHaveClass('custom-open')
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByTestId('dropdown-content')).toHaveClass('custom-popup')
expect(screen.getByRole('menu')).toHaveClass('custom-popup')
})
it('can hide the trigger arrow for compact integrations placement', () => {
@ -279,7 +136,7 @@ describe('InstallPluginDropdown', () => {
/>,
)
const trigger = screen.getByTestId('dropdown-trigger')
const trigger = getTrigger('Install')
expect(trigger).toHaveTextContent('Install')
expect(screen.getByTestId('add-circle-fill-icon')).toHaveClass('size-4', 'shrink-0')
@ -293,7 +150,7 @@ describe('InstallPluginDropdown', () => {
<InstallPluginDropdown disabled onSwitchToMarketplaceTab={onSwitchToMarketplaceTab} />,
)
const trigger = screen.getByTestId('dropdown-trigger')
const trigger = getTrigger()
expect(trigger).toBeDisabled()
@ -304,7 +161,7 @@ describe('InstallPluginDropdown', () => {
},
})
expect(screen.queryByTestId('dropdown-content')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.queryByTestId('local-modal')).not.toBeInTheDocument()
expect(onSwitchToMarketplaceTab).not.toHaveBeenCalled()
})
@ -314,7 +171,7 @@ describe('InstallPluginDropdown', () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument()
expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument()
@ -325,7 +182,7 @@ describe('InstallPluginDropdown', () => {
const onSwitchToMarketplaceTab = vi.fn()
render(<InstallPluginDropdown onSwitchToMarketplaceTab={onSwitchToMarketplaceTab} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.marketplace'))
expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1)
@ -334,7 +191,7 @@ describe('InstallPluginDropdown', () => {
it('opens the github installer when github is selected', async () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.github'))
expect(await screen.findByTestId('github-modal')).toBeInTheDocument()
@ -343,7 +200,7 @@ describe('InstallPluginDropdown', () => {
it('opens the local package installer when a file is selected', () => {
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.local'))
fireEvent.change(container.querySelector('input[type="file"]')!, {
target: {
@ -360,7 +217,7 @@ describe('InstallPluginDropdown', () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.local'))
expect(clickSpy).toHaveBeenCalledTimes(1)
@ -370,7 +227,7 @@ describe('InstallPluginDropdown', () => {
it('closes the github installer when the modal requests close', async () => {
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.github'))
fireEvent.click(await screen.findByTestId('close-github-modal'))
@ -380,7 +237,7 @@ describe('InstallPluginDropdown', () => {
it('closes the local package installer when the modal requests close', () => {
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(getTrigger())
fireEvent.click(screen.getByText('plugin.source.local'))
fireEvent.change(container.querySelector('input[type="file"]')!, {
target: {

View File

@ -2,8 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
@ -57,7 +55,7 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByRole('button', { name: /Tool/ })
const clearSvg = trigger.querySelector('svg')
fireEvent.click(clearSvg!)
expect(mockOnChange).toHaveBeenCalledWith([])
@ -65,7 +63,7 @@ describe('CategoriesFilter', () => {
it('should render category options in dropdown', () => {
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'plugin.allCategories' }))
expect(screen.getByText('Tool'))!.toBeInTheDocument()
expect(screen.getByText('Model'))!.toBeInTheDocument()
@ -76,7 +74,7 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render(<CategoriesFilter value={[]} onChange={mockOnChange} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'plugin.allCategories' }))
fireEvent.click(screen.getByText('Tool'))
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
})
@ -85,7 +83,7 @@ describe('CategoriesFilter', () => {
const mockOnChange = vi.fn()
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByRole('button', { name: /Tool/ }))
const toolElements = screen.getAllByText('Tool')
fireEvent.click(toolElements[toolElements.length - 1]!)
expect(mockOnChange).toHaveBeenCalledWith([])
@ -94,7 +92,7 @@ describe('CategoriesFilter', () => {
it('should filter categories by search text', () => {
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'plugin.allCategories' }))
fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), {
target: { value: 'mod' },
})

View File

@ -18,8 +18,6 @@ vi.mock('../../../hooks', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
describe('TagFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -38,12 +36,12 @@ describe('TagFilter', () => {
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('filters options by search text and toggles tag selection', () => {
it('filters options by search text and toggles tag selection', async () => {
const onChange = vi.fn()
render(<TagFilter value={['agent']} onChange={onChange} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
const portal = screen.getByTestId('popover-content')
fireEvent.click(screen.getByRole('button', { name: /Agent/ }))
const portal = await screen.findByRole('dialog')
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), {
target: { value: 'ra' },
@ -61,18 +59,18 @@ describe('TagFilter', () => {
const onChange = vi.fn()
render(<TagFilter value={['agent']} onChange={onChange} />)
const trigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByRole('button', { name: /Agent/ })
fireEvent.click(trigger.querySelector('.i-ri-close-circle-fill')!)
expect(onChange).toHaveBeenCalledWith([])
})
it('removes a selected tag when clicking the same option again', () => {
it('removes a selected tag when clicking the same option again', async () => {
const onChange = vi.fn()
render(<TagFilter value={['agent']} onChange={onChange} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent'))
fireEvent.click(screen.getByRole('button', { name: /Agent/ }))
fireEvent.click(within(await screen.findByRole('dialog')).getByText('Agent'))
expect(onChange).toHaveBeenCalledWith([])
})

View File

@ -17,33 +17,6 @@ const mockSystemFeatures = {
const render = (ui: ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: mockSystemFeatures })
let mockDialogOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockDialogOnOpenChange = onOpenChange
return open === false ? null : <>{children}</>
},
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="modal" className={className}>
{children}
</div>
),
DialogCloseButton: () => (
<button data-testid="modal-close" onClick={() => mockDialogOnOpenChange?.(false)}>
Close
</button>
),
}))
// Mock OptionCard component
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
default: ({
@ -280,7 +253,7 @@ describe('reference-setting-modal', () => {
// Assert
// Assert
expect(screen.getByTestId('modal-close'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' }))!.toBeInTheDocument()
})
it('should disable permission controls with settings permissions tooltip beside titles when RBAC is enabled', () => {
@ -381,7 +354,7 @@ describe('reference-setting-modal', () => {
// Act
render(<ReferenceSettingModal {...defaultProps} onHide={onHide} />)
fireEvent.click(screen.getByTestId('modal-close'))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
@ -843,7 +816,7 @@ describe('reference-setting-modal', () => {
// Assert - modal should be visible
// Assert - modal should be visible
expect(screen.getByTestId('modal'))!.toBeInTheDocument()
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
})

View File

@ -88,126 +88,11 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
// Mock popover component for ToolPicker and StrategyPicker
let mockPopoverOpen = false
let forcePopoverContentVisible = false // Allow tests to force content visibility
let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({
children,
open = false,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
mockPopoverOpen = open
mockPopoverOnOpenChange = onOpenChange
return (
<div data-testid="popover" data-open={open}>
{children}
</div>
)
},
PopoverTrigger: ({
children,
render,
onClick,
className,
}: {
children?: React.ReactNode
render?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
className?: string
}) => (
<div
data-testid="popover-trigger"
role="button"
aria-label="popover trigger"
tabIndex={0}
onClick={(e) => {
onClick?.(e)
if (!onClick) mockPopoverOnOpenChange?.(!mockPopoverOpen)
}}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !onClick)
mockPopoverOnOpenChange?.(!mockPopoverOpen)
}}
className={className}
>
{render ?? children}
</div>
),
PopoverContent: ({
children,
className,
popupClassName,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
if (!mockPopoverOpen && !forcePopoverContentVisible) return null
return (
<div
data-testid="popover-content"
className={[className, popupClassName].filter(Boolean).join(' ')}
>
{children}
</div>
)
},
}))
// Mock TimePicker component - simplified stateless mock
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: ({
value,
onChange,
onClear,
renderTrigger,
}: {
value: { format: (f: string) => string }
onChange: (v: unknown) => void
onClear: () => void
title?: string
renderTrigger: (
props: React.HTMLAttributes<HTMLElement>,
state: { open: boolean },
params: { inputElem: React.ReactNode; onClick: () => void },
) => React.ReactNode
}) => {
const inputElem = <span data-testid="time-input">{value.format('HH:mm')}</span>
return (
<div data-testid="time-picker">
{renderTrigger({}, { open: false }, { inputElem, onClick: () => {} })}
<div data-testid="time-picker-dropdown">
<button
data-testid="time-picker-set"
onClick={() => {
onChange(dayjs().hour(10).minute(30))
}}
>
Set 10:30
</button>
<button
data-testid="time-picker-clear"
onClick={() => {
onClear()
}}
>
Clear
</button>
</div>
</div>
)
},
}))
// Mock utils from date-and-time-picker
vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', () => ({
vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', async (importOriginal) => ({
...(await importOriginal<
typeof import('@/app/components/base/date-and-time-picker/utils/dayjs')
>()),
convertTimezoneToOffsetStr: (tz: string) => {
if (tz === 'America/New_York') return 'GMT-5'
if (tz === 'Asia/Shanghai') return 'GMT+8'
@ -390,9 +275,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
describe('auto-update-setting', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPopoverOpen = false
mockPopoverOnOpenChange = undefined
forcePopoverContentVisible = false
mockPluginsData.plugins = []
})
@ -862,7 +744,6 @@ describe('auto-update-setting', () => {
it('should render search box and tabs when isShow is true', () => {
// Arrange
mockPopoverOpen = true
// Act
render(<ToolPicker {...defaultProps} isShow={true} />)
@ -873,7 +754,6 @@ describe('auto-update-setting', () => {
it('should show NoDataPlaceholder when no plugins and no search query', () => {
// Arrange
mockPopoverOpen = true
mockPluginsData.plugins = []
// Act
@ -915,7 +795,6 @@ describe('auto-update-setting', () => {
it('should filter out non-marketplace plugins', () => {
// Arrange
mockPopoverOpen = true
// Act
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
@ -926,7 +805,6 @@ describe('auto-update-setting', () => {
it('should filter by search query', () => {
// Arrange
mockPopoverOpen = true
// Act
renderWithQueryClient(<ToolPicker {...defaultProps} isShow={true} />)
@ -947,15 +825,14 @@ describe('auto-update-setting', () => {
// Act
render(<ToolPicker {...defaultProps} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('popover-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'Select Plugins' }))
// Assert
expect(onShowChange).toHaveBeenCalledWith(true)
expect(onShowChange).toHaveBeenCalledWith(true, expect.any(Object))
})
it('should call onChange when plugin is selected', () => {
// Arrange
mockPopoverOpen = true
mockPluginsData.plugins = [
createMockPluginDetail({
plugin_id: 'test-plugin',
@ -977,7 +854,6 @@ describe('auto-update-setting', () => {
it('should unselect plugin when already selected', () => {
// Arrange
mockPopoverOpen = true
mockPluginsData.plugins = [
createMockPluginDetail({
plugin_id: 'test-plugin',
@ -1128,7 +1004,6 @@ describe('auto-update-setting', () => {
// Assert
expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument()
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
})
it('should hide time picker and plugins selection when strategy is disabled', () => {
@ -1142,7 +1017,6 @@ describe('auto-update-setting', () => {
// Assert
expect(screen.queryByText('plugin.autoUpdate.updateTime')).not.toBeInTheDocument()
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument()
})
it('should show plugins picker when mode is not update_all', () => {
@ -1284,46 +1158,6 @@ describe('auto-update-setting', () => {
)
})
it('should call onChange with updated time when time changes', () => {
// Arrange
const onChange = vi.fn()
const payload = createMockAutoUpdateConfig({
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
})
// Act
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
// Click time picker trigger
fireEvent.click(screen.getByRole('button', { name: /GMT-5/ }))
// Set time
fireEvent.click(screen.getByRole('button', { name: 'Set 10:30' }))
// Assert
expect(onChange).toHaveBeenCalled()
})
it('should call onChange with 0 when time is cleared', () => {
// Arrange
const onChange = vi.fn()
const payload = createMockAutoUpdateConfig({
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
})
// Act
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
// Click time picker trigger
fireEvent.click(screen.getByRole('button', { name: /GMT-5/ }))
// Clear time
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
// Assert
expect(onChange).toHaveBeenCalled()
})
it('should call onChange with include_plugins when in partial mode', () => {
// Arrange
const onChange = vi.fn()
@ -1390,20 +1224,6 @@ describe('auto-update-setting', () => {
})
describe('Callback Memoization', () => {
it('minuteFilter should filter to 15 minute intervals', () => {
// Arrange
const payload = createMockAutoUpdateConfig({
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
})
// Act
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
// The minuteFilter is passed to TimePicker internally
// We verify the component renders correctly
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
})
it('handleChange should preserve other config values', () => {
// Arrange
const onChange = vi.fn()
@ -1523,20 +1343,6 @@ describe('auto-update-setting', () => {
expect(screen.getByText('plugin.autoUpdate.updateSettings')).toBeInTheDocument()
})
it('should handle null timezone gracefully', () => {
// This tests the timezone! non-null assertion in the component
// The mock provides a valid timezone, so the component should work
const payload = createMockAutoUpdateConfig({
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
})
// Act
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
// Assert - should render without errors
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
})
it('should render timezone offset correctly', () => {
// Arrange
const payload = createMockAutoUpdateConfig({
@ -1630,7 +1436,7 @@ describe('auto-update-setting', () => {
)
// Assert - initially disabled
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.autoUpdate.updateTime')).not.toBeInTheDocument()
// Simulate enabling updates
currentPayload = createMockAutoUpdateConfig({
@ -1641,7 +1447,7 @@ describe('auto-update-setting', () => {
rerender(<AutoUpdateSetting payload={currentPayload} onChange={onChange} />)
// Assert - time picker and plugins visible
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.operation.select')).toBeInTheDocument()
})

View File

@ -28,10 +28,6 @@ vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => mockInstalledPluginList,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}))
vi.mock('../no-plugin-selected', () => ({
default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) => (
<div data-testid="no-plugin-selected">{updateMode}</div>

View File

@ -19,56 +19,6 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => (
<PopoverContext.Provider
value={{ open: !!open, setOpen: (nextOpen: boolean) => onOpenChange?.(nextOpen) }}
>
{children}
</PopoverContext.Provider>
)
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext)
return <div onClick={() => setOpen(!open)}>{render}</div>
}
const PopoverContent = ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => {
const { open } = React.useContext(PopoverContext)
return open ? (
<div data-testid="popover-content" className={className}>
{children}
</div>
) : null
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
vi.mock('@/app/components/plugins/marketplace/search-box', () => ({
default: ({
search,
@ -151,7 +101,7 @@ describe('ToolPicker', () => {
const onShowChange = vi.fn()
render(
<ToolPicker
trigger={<span>trigger</span>}
trigger={<button type="button">trigger</button>}
value={[]}
onChange={vi.fn()}
isShow={false}
@ -161,7 +111,7 @@ describe('ToolPicker', () => {
fireEvent.click(screen.getByText('trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
expect(onShowChange).toHaveBeenCalledWith(true, expect.any(Object))
})
it('renders loading content while installed plugins are loading', () => {

View File

@ -28,13 +28,6 @@ vi.mock('@/context/workspace-state', async () => {
}))
})
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button>close dialog</button>,
}))
vi.mock('@/app/components/base/badge/index', () => ({
__esModule: true,
BadgeState: {
@ -43,22 +36,6 @@ vi.mock('@/app/components/base/badge/index', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mockToastError,

View File

@ -60,14 +60,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, ...props }: Record<string, unknown>) => (
<button onClick={onClick as () => void} {...props}>
{children as string}
</button>
),
}))
vi.mock('../screenshot', () => ({
default: () => <div data-testid="screenshot" />,
}))

View File

@ -16,27 +16,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="modal" className={className}>
{children}
</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<h2 className={className}>{children}</h2>
),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, ...props }: Record<string, unknown>) => (
<button onClick={onClick as () => void} disabled={disabled as boolean} {...props}>
{children as string}
</button>
),
}))
vi.mock('@/app/components/base/input', () => ({
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
<input
@ -78,7 +57,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
it('should render modal with title', () => {
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
})

View File

@ -1,4 +1,3 @@
import type { PropsWithChildren } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DSLImportStatus } from '@/models/app'
@ -107,44 +106,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
onClick,
disabled,
className,
variant,
loading,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
variant?: string
loading?: boolean
}) => (
<button
onClick={onClick}
disabled={disabled}
className={className}
data-variant={variant}
data-loading={loading}
>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: PropsWithChildren<{ open?: boolean }>) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
<div data-testid="modal" className={className}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/constants', () => ({
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
}))
@ -310,34 +271,6 @@ describe('UpdateDSLModal', () => {
})
})
describe('edge cases', () => {
it('should handle missing onImport callback', () => {
const props = {
onCancel: mockOnCancel,
onBackup: mockOnBackup,
}
render(<UpdateDSLModal {...props} />)
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('should render import button with primary destructive variant', () => {
render(<UpdateDSLModal {...defaultProps} />)
const importButton = screen.getByText('workflow.common.overwriteAndImport')
expect(importButton).toHaveAttribute('data-variant', 'primary')
})
it('should render backup button with secondary variant', () => {
render(<UpdateDSLModal {...defaultProps} />)
const backupButtonText = screen.getByText('workflow.common.backupCurrentDraft')
const backupButton = backupButtonText.closest('button')
expect(backupButton).toHaveAttribute('data-variant', 'secondary')
})
})
describe('import flow', () => {
it('should call importDSL when import button is clicked with file content', async () => {
render(<UpdateDSLModal {...defaultProps} />)
@ -718,13 +651,8 @@ describe('UpdateDSLModal', () => {
{ timeout: 1000 },
)
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
const errorModalCancelButton = cancelButtons.find(
(btn) => btn.getAttribute('data-variant') === 'secondary',
)
if (errorModalCancelButton) {
fireEvent.click(errorModalCancelButton)
}
const cancelButtons = screen.getAllByRole('button', { name: 'app.newApp.Cancel' })
fireEvent.click(cancelButtons.at(-1)!)
await waitFor(() => {
expect(screen.queryByText('app.newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()

View File

@ -36,54 +36,6 @@ const triggerHotkey = (hotkey: string) => {
})
}
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
<button
onClick={onClick as (() => void) | undefined}
disabled={disabled as boolean | undefined}
data-variant={variant as string | undefined}
className={className as string | undefined}
>
{children as React.ReactNode}
</button>
),
}))
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) =>
open ? (
<div role="alertdialog">
{children}
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
) : null,
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => (
<button>{children}</button>
),
AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => (
<button
onClick={onClick as (() => void) | undefined}
disabled={disabled as boolean | undefined}
>
{children as React.ReactNode}
</button>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
@ -348,7 +300,7 @@ describe('publisher', () => {
it('should render portal element in closed state by default', () => {
renderWithQueryClient(<Publisher />)
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
})
@ -900,7 +852,7 @@ describe('publisher', () => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('alert-dialog-close'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()

View File

@ -11,46 +11,6 @@ const render = (ui: React.ReactElement) => {
return renderWithConsoleState(ui, { wrapper })
}
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) =>
open ? (
<div role="alertdialog">
{children}
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
Close
</button>
</div>
) : null,
AlertDialogActions: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => (
<button>{children}</button>
),
AlertDialogConfirmButton: ({
children,
onClick,
disabled,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
AlertDialogContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}))
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
const toastMocks = vi.hoisted(() => ({
@ -137,19 +97,6 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
<button
onClick={onClick as () => void}
disabled={disabled as boolean}
data-variant={variant as string}
className={className as string}
>
{children as React.ReactNode}
</button>
),
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <hr />,
}))
@ -459,7 +406,7 @@ describe('Popup', () => {
render(<Popup />)
fireEvent.click(screen.getByTestId('alert-dialog-close'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(hideConfirm).toHaveBeenCalledTimes(1)
})

View File

@ -2,71 +2,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LabelSelector from '../selector'
vi.mock('@langgenius/dify-ui/popover', async () => {
const React = await import('react')
const PopoverContext = React.createContext({
open: false,
setOpen: (_open: boolean) => {},
})
const Popover = ({
children,
open: controlledOpen,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const isControlled = controlledOpen !== undefined
const open = isControlled ? !!controlledOpen : uncontrolledOpen
const setOpen = (nextOpen: boolean) => {
if (!isControlled) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider>
}
const PopoverTrigger = ({
children,
className,
render,
}: {
children?: React.ReactNode
className?: string
render?: React.ReactNode
}) => {
const { open, setOpen } = React.useContext(PopoverContext)
if (render) {
return <div onClick={() => setOpen(!open)}>{render}</div>
}
return (
<button type="button" className={className} onClick={() => setOpen(!open)}>
{children}
</button>
)
}
const PopoverContent = ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext)
if (!open) return null
return <div {...props}>{children}</div>
}
return {
Popover,
PopoverTrigger,
PopoverContent,
}
})
// Mock useTags hook with controlled test data
const mockTags = [
{ name: 'agent', label: 'Agent' },

View File

@ -2,118 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OperationDropdown from '../operation-dropdown'
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{
isOpen: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
const [internalOpen, setInternalOpen] = React.useState(open ?? false)
const isOpen = open ?? internalOpen
const setOpen = (nextOpen: boolean) => {
if (open === undefined) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
}
return (
<DropdownMenuContext value={{ isOpen, setOpen }}>
<div data-testid="dropdown-menu" data-open={isOpen}>
{children}
</div>
</DropdownMenuContext>
)
},
DropdownMenuTrigger: ({
children,
render,
onClick,
}: {
children: React.ReactNode
render?: React.ReactElement
onClick?: React.MouseEventHandler<HTMLElement>
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e)
setOpen(!isOpen)
}
if (render)
return React.cloneElement(
render,
{ 'data-testid': 'dropdown-trigger', onClick: handleClick } as Record<string, unknown>,
children,
)
return (
<button data-testid="dropdown-trigger" onClick={handleClick}>
{children}
</button>
)
},
DropdownMenuContent: ({
children,
className,
popupClassName,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
}) => {
const { isOpen } = useDropdownMenuContext()
return isOpen ? (
<div
data-testid="dropdown-content"
className={[className, popupClassName].filter(Boolean).join(' ')}
>
{children}
</div>
) : null
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
data-testid="dropdown-item"
className={className}
onClick={(e) => {
onClick?.(e)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
describe('OperationDropdown', () => {
const defaultProps = {
onEdit: vi.fn(),
@ -145,7 +33,7 @@ describe('OperationDropdown', () => {
it('should open dropdown when trigger is clicked', async () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
@ -155,17 +43,17 @@ describe('OperationDropdown', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
expect(onOpenChange).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(onOpenChange).toHaveBeenCalledWith(true, expect.any(Object))
})
it('should close dropdown when trigger is clicked again', async () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByTestId('dropdown-trigger'))
expect(onOpenChange).toHaveBeenLastCalledWith(false)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.any(Object))
})
})
@ -174,7 +62,7 @@ describe('OperationDropdown', () => {
const onEdit = vi.fn()
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(screen.getByText('tools.mcp.operation.edit'))
expect(onEdit).toHaveBeenCalledTimes(1)
})
@ -183,7 +71,7 @@ describe('OperationDropdown', () => {
const onRemove = vi.fn()
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(screen.getByText('tools.mcp.operation.remove'))
expect(onRemove).toHaveBeenCalledTimes(1)
})
@ -192,20 +80,20 @@ describe('OperationDropdown', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
onOpenChange.mockClear()
fireEvent.click(screen.getByText('tools.mcp.operation.edit'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onOpenChange).toHaveBeenCalledWith(false, expect.any(Object))
})
it('should close dropdown after remove is clicked', () => {
const onOpenChange = vi.fn()
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
onOpenChange.mockClear()
fireEvent.click(screen.getByText('tools.mcp.operation.remove'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onOpenChange).toHaveBeenCalledWith(false, expect.any(Object))
})
})
@ -213,7 +101,7 @@ describe('OperationDropdown', () => {
it('should have correct dropdown width', () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
const dropdown = document.querySelector('.w-\\[160px\\]')
expect(dropdown).toBeInTheDocument()
})
@ -221,8 +109,8 @@ describe('OperationDropdown', () => {
it('should render dropdown content through the shared popup shell', () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('dropdown-trigger'))
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.getByRole('menu')).toBeInTheDocument()
})
})

View File

@ -7,8 +7,6 @@ import WorkflowToolConfigureButton from '../configure-button'
import { WorkflowToolDrawer } from '../index'
import MethodSelector from '../method-selector'
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
// Mock Next.js navigation
const mockPush = vi.fn()
vi.mock('@/next/navigation', () => ({
@ -1316,11 +1314,11 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('popover-trigger'))
await user.click(screen.getByRole('button'))
// Assert
// Assert
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
expect(screen.getByText('tools.createTool.toolInput.methodParameterTip')).toBeInTheDocument()
})
it('should call onChange with llm when parameter option clicked', async () => {
@ -1334,7 +1332,7 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('popover-trigger'))
await user.click(screen.getByRole('button'))
const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0]
await user.click(paramOption!)
@ -1354,7 +1352,7 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('popover-trigger'))
await user.click(screen.getByRole('button'))
const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting')
await user.click(settingOption)
@ -1373,51 +1371,17 @@ describe('MethodSelector', () => {
// Act
render(<MethodSelector {...props} />)
const trigger = screen.getByRole('button')
// First click - open
await user.click(screen.getByTestId('popover-trigger'))
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()
await user.click(trigger)
expect(screen.getByText('tools.createTool.toolInput.methodParameterTip')).toBeInTheDocument()
// Second click - close
await user.click(screen.getByTestId('popover-trigger'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
})
})
// Props Tests (REQUIRED)
describe('Props', () => {
it('should show check icon for selected llm value', async () => {
// Arrange
const user = userEvent.setup()
const props = {
value: 'llm',
onChange: vi.fn(),
}
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('popover-trigger'))
// Assert - the first option (llm) should have a check icon container
const content = screen.getByTestId('popover-content')
expect(content)!.toBeInTheDocument()
})
it('should show check icon for selected form value', async () => {
// Arrange
const user = userEvent.setup()
const props = {
value: 'form',
onChange: vi.fn(),
}
// Act
render(<MethodSelector {...props} />)
await user.click(screen.getByTestId('popover-trigger'))
// Assert
const content = screen.getByTestId('popover-content')
expect(content)!.toBeInTheDocument()
await user.click(trigger)
expect(
screen.queryByText('tools.createTool.toolInput.methodParameterTip'),
).not.toBeInTheDocument()
})
})

View File

@ -1,33 +1,8 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowOnboardingModal from '../index'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({
open,
onSelect,
trigger,
}: {
open?: boolean
onSelect: (type: BlockEnum, config?: Record<string, unknown>) => void
trigger?: ((open: boolean) => ReactNode) | ReactNode
}) => (
<div>
{typeof trigger === 'function' ? trigger(Boolean(open)) : trigger}
{open && (
<button
type="button"
onClick={() => onSelect(BlockEnum.TriggerWebhook, { config: 'test' })}
>
Select Trigger Webhook
</button>
)}
</div>
),
}))
describe('WorkflowOnboardingModal', () => {
it('only renders while onboarding is open', () => {
const props = { onClose: vi.fn(), onSelectStartNode: vi.fn() }
@ -50,20 +25,6 @@ describe('WorkflowOnboardingModal', () => {
expect(onSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
})
it('forwards trigger configuration', async () => {
const user = userEvent.setup()
const onSelectStartNode = vi.fn()
render(
<WorkflowOnboardingModal isShow onClose={vi.fn()} onSelectStartNode={onSelectStartNode} />,
)
await user.click(screen.getByText('workflow.onboarding.trigger'))
await user.click(screen.getByRole('button', { name: 'Select Trigger Webhook' }))
expect(onSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' })
})
it('closes from the dialog control', async () => {
const user = userEvent.setup()
const onClose = vi.fn()

View File

@ -1,37 +1,7 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeSelectionPanel from '../start-node-selection-panel'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({
open,
onOpenChange,
onSelect,
trigger,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (type: BlockEnum) => void
trigger: (() => ReactNode) | ReactNode
}) => (
<div>
{typeof trigger === 'function' ? trigger() : trigger}
{open && (
<div>
<button type="button" onClick={() => onSelect(BlockEnum.TriggerSchedule)}>
Select Schedule
</button>
<button type="button" onClick={() => onOpenChange(false)}>
Close selector
</button>
</div>
)}
</div>
),
}))
describe('StartNodeSelectionPanel', () => {
it('selects a user input start node', async () => {
const user = userEvent.setup()
@ -44,32 +14,4 @@ describe('StartNodeSelectionPanel', () => {
expect(onSelectUserInput).toHaveBeenCalledTimes(1)
})
it('selects a trigger start node and closes the selector', async () => {
const user = userEvent.setup()
const onSelectTrigger = vi.fn()
render(
<StartNodeSelectionPanel onSelectUserInput={vi.fn()} onSelectTrigger={onSelectTrigger} />,
)
await user.click(screen.getByText('workflow.onboarding.trigger'))
await user.click(screen.getByRole('button', { name: 'Select Schedule' }))
expect(onSelectTrigger).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
expect(screen.queryByRole('button', { name: 'Select Schedule' })).not.toBeInTheDocument()
})
it('closes the trigger selector without selecting a node', async () => {
const user = userEvent.setup()
const onSelectTrigger = vi.fn()
render(
<StartNodeSelectionPanel onSelectUserInput={vi.fn()} onSelectTrigger={onSelectTrigger} />,
)
await user.click(screen.getByText('workflow.onboarding.trigger'))
await user.click(screen.getByRole('button', { name: 'Close selector' }))
expect(onSelectTrigger).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Select Schedule' })).not.toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import { Position } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import CustomEdge from '../custom-edge'
@ -7,7 +7,6 @@ import { BlockEnum, NodeRunningStatus } from '../types'
const mockUseAvailableBlocks = vi.hoisted(() => vi.fn())
const mockUseNodesInteractions = vi.hoisted(() => vi.fn())
const mockBlockSelector = vi.hoisted(() => vi.fn())
const mockGradientRender = vi.hoisted(() => vi.fn())
vi.mock('reactflow', () => ({
@ -59,30 +58,6 @@ vi.mock('../hooks/use-nodes-interactions', async (importOriginal) => {
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: (props: {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (nodeType: string, pluginDefaultValue?: Record<string, unknown>) => void
availableBlocksTypes: string[]
}) => {
mockBlockSelector(props)
return (
<button
type="button"
data-testid="block-selector"
onClick={() => {
props.onOpenChange(true)
props.onSelect('llm', { provider: 'openai' })
}}
>
{props.availableBlocksTypes.join(',')}
</button>
)
},
}))
vi.mock('@/app/components/workflow/custom-edge-linear-gradient-render', () => ({
__esModule: true,
default: (props: { id: string; startColor: string; stopColor: string }) => {
@ -106,7 +81,7 @@ describe('CustomEdge', () => {
})
})
it('should render a gradient edge and insert a node between the source and target', () => {
it('should render a gradient edge and its real insert-node trigger', () => {
render(
<CustomEdge
id="edge-1"
@ -149,27 +124,12 @@ describe('CustomEdge', () => {
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-stroke', 'url(#edge-1)')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-opacity', '0.3')
expect(screen.getByTestId('base-edge')).toHaveAttribute('data-dasharray', '8 8')
expect(screen.getByTestId('block-selector')).toHaveTextContent('llm')
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
const addBlockTrigger = screen.getByRole('button', { name: 'workflow.common.addBlock' })
expect(addBlockTrigger.parentElement).toHaveStyle({
transform: 'translate(-50%, -50%) translate(24px, 48px)',
opacity: '0.7',
zIndex: '1001',
})
fireEvent.click(screen.getByTestId('block-selector'))
expect(mockHandleNodeAdd).toHaveBeenCalledWith(
{
nodeType: 'llm',
pluginDefaultValue: { provider: 'openai' },
},
{
prevNodeId: 'source-node',
prevNodeSourceHandle: 'source',
nextNodeId: 'target-node',
nextNodeTargetHandle: 'target',
},
)
})
it('should prefer the running stroke color when the edge is selected', () => {
@ -258,7 +218,9 @@ describe('CustomEdge', () => {
'data-stroke',
'var(--color-workflow-link-line-normal)',
)
expect(screen.getByTestId('block-selector').parentElement).toHaveStyle({
expect(
screen.getByRole('button', { name: 'workflow.common.addBlock' }).parentElement,
).toHaveStyle({
opacity: '0',
pointerEvents: 'none',
})

View File

@ -40,11 +40,6 @@ vi.mock('@/context/account-state', async () => {
return createAccountStateModuleMock(() => mockConsoleState)
})
vi.mock('@langgenius/dify-ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
vi.mock('./mention-input', () => ({
MentionInput: ((props: MentionInputProps) => {
mentionInputProps = props

View File

@ -46,10 +46,6 @@ vi.mock('../store', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="mention-avatar">{name}</div>,
}))
const mentionUsers: UserProfile[] = [
{
id: 'user-2',

View File

@ -1,5 +1,6 @@
import type { WorkflowCommentDetail } from '@/app/components/workflow/comment/types'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { CommentThread } from './thread'
@ -66,45 +67,6 @@ vi.mock('@/app/components/base/inline-delete-confirm', () => ({
),
}))
vi.mock('@langgenius/dify-ui/avatar', () => ({
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
AvatarRoot: ({ children }: { children: React.ReactNode }) => (
<div data-testid="avatar-root">{children}</div>
),
AvatarImage: ({ alt }: { alt: string }) => <div data-testid="avatar-image">{alt}</div>,
AvatarFallback: ({ children }: { children: React.ReactNode }) => (
<div data-testid="avatar-fallback">{children}</div>
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button type="button" {...props}>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
children,
render,
...props
}: React.ComponentProps<'button'> & { children?: React.ReactNode; render?: React.ReactNode }) => {
if (render) return <>{render}</>
return (
<button type="button" {...props}>
{children}
</button>
)
},
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('./mention-input', () => ({
MentionInput: ({
placeholder,
@ -264,6 +226,7 @@ describe('CommentThread', () => {
})
it('supports editing and direct deleting an existing reply', async () => {
const user = userEvent.setup()
const onReplyEdit = vi.fn()
const onReplyDeleteDirect = vi.fn()
@ -276,18 +239,20 @@ describe('CommentThread', () => {
/>,
)
fireEvent.click(screen.getByText('workflow.comments.actions.editReply'))
fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.editReply'))
await user.click(screen.getByLabelText('workflow.comments.aria.replyActions'))
await user.click(await screen.findByText('workflow.comments.actions.editReply'))
await user.click(screen.getByText('submit-workflow.comments.placeholder.editReply'))
await waitFor(() => {
expect(onReplyEdit).toHaveBeenCalledWith('reply-1', 'first reply', ['user-2'])
})
await waitFor(() => {
expect(screen.getByText('workflow.comments.actions.deleteReply')).toBeInTheDocument()
expect(screen.getByLabelText('workflow.comments.aria.replyActions')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('workflow.comments.actions.deleteReply'))
fireEvent.click(screen.getByTestId('confirm-delete-reply'))
await user.click(screen.getByLabelText('workflow.comments.aria.replyActions'))
await user.click(await screen.findByText('workflow.comments.actions.deleteReply'))
await user.click(screen.getByTestId('confirm-delete-reply'))
expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1')
})

View File

@ -1,5 +1,5 @@
import type * as React from 'react'
import type { TriggerOption } from '../test-run-menu'
import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import { fireEvent, render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TriggerType } from '../test-run-menu'
@ -10,53 +10,6 @@ import {
useShortcutMenu,
} from '../test-run-menu-helpers'
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => (
<button type="button" className={className} onClick={onClick}>
{children}
</button>
),
}
})
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,
@ -79,7 +32,13 @@ describe('test-run-menu helpers', () => {
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~')
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1')
render(<OptionRow option={option} shortcutKey="1" onSelect={onSelect} />)
render(
<DropdownMenu open>
<DropdownMenuContent>
<OptionRow option={option} shortcutKey="1" onSelect={onSelect} />
</DropdownMenuContent>
</DropdownMenu>,
)
expect(screen.getByText('1')).toBeInTheDocument()

View File

@ -5,104 +5,6 @@ import { act } from 'react'
import * as React from 'react'
import TestRunMenu, { TriggerType } from '../test-run-menu'
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const React = await import('react')
const DropdownMenuContext = React.createContext<{
open: boolean
setOpen: (open: boolean) => void
} | null>(null)
const useDropdownMenuContext = () => {
const context = React.use(DropdownMenuContext)
if (!context) throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
return context
}
return {
DropdownMenu: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open: boolean
onOpenChange?: (open: boolean) => void
}) => (
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
<div>{children}</div>
</DropdownMenuContext>
),
DropdownMenuTrigger: ({
children,
render,
}: {
children: React.ReactNode
render?: React.ReactElement<{ children?: React.ReactNode }>
}) => {
const { open, setOpen } = useDropdownMenuContext()
if (render) {
return React.cloneElement(
render,
{ onClick: () => setOpen(!open) } as Record<string, unknown>,
children ?? render.props.children,
)
}
return (
<button type="button" onClick={() => setOpen(!open)}>
{children}
</button>
)
},
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
const { open } = useDropdownMenuContext()
return open ? <div>{children}</div> : null
},
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => <div className={className}>{children}</div>,
DropdownMenuGroupLabel: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => <div className={className}>{children}</div>,
DropdownMenuSeparator: ({ className }: { className?: string }) => (
<div className={className} data-testid="dropdown-separator" />
),
DropdownMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: React.MouseEventHandler<HTMLButtonElement>
className?: string
}) => {
const { setOpen } = useDropdownMenuContext()
return (
<button
type="button"
className={className}
onClick={(event) => {
onClick?.(event)
setOpen(false)
}}
>
{children}
</button>
)
},
}
})
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
id: 'user-input',
type: TriggerType.UserInput,

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VersionHistoryButton } from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light'
@ -29,12 +30,6 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('VersionHistoryButton', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -66,9 +61,10 @@ describe('VersionHistoryButton', () => {
})
it('should render the tooltip popup content on hover', async () => {
const user = userEvent.setup()
render(<VersionHistoryButton onClick={vi.fn()} />)
fireEvent.mouseEnter(screen.getByRole('button'))
await user.hover(screen.getByRole('button'))
expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
})

Some files were not shown because too many files have changed in this diff Show More