mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 09:58:04 +08:00
test(workflow): add helper specs and raise targeted workflow coverage (#33995)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,128 @@
|
||||
import type { TriggerOption } from '../test-run-menu'
|
||||
import { fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { TriggerType } from '../test-run-menu'
|
||||
import {
|
||||
getNormalizedShortcutKey,
|
||||
OptionRow,
|
||||
SingleOptionTrigger,
|
||||
useShortcutMenu,
|
||||
} from '../test-run-menu-helpers'
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
|
||||
id: 'user-input',
|
||||
type: TriggerType.UserInput,
|
||||
name: 'User Input',
|
||||
icon: <span>icon</span>,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('test-run-menu helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should normalize shortcut keys and render option rows with clickable shortcuts', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const option = createOption()
|
||||
|
||||
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~')
|
||||
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1')
|
||||
|
||||
render(
|
||||
<OptionRow
|
||||
option={option}
|
||||
shortcutKey="1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('User Input'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(option)
|
||||
})
|
||||
|
||||
it('should handle shortcut key presses only when the menu is open and the event is eligible', () => {
|
||||
const handleSelect = vi.fn()
|
||||
const option = createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' })
|
||||
|
||||
const { rerender, unmount } = renderHook(({ open }) => useShortcutMenu({
|
||||
open,
|
||||
shortcutMappings: [{ option, shortcutKey: '~' }],
|
||||
handleSelect,
|
||||
}), {
|
||||
initialProps: { open: true },
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: '`' })
|
||||
fireEvent.keyDown(window, { key: '`', altKey: true })
|
||||
fireEvent.keyDown(window, { key: '`', repeat: true })
|
||||
|
||||
const preventedEvent = new KeyboardEvent('keydown', { key: '`', cancelable: true })
|
||||
preventedEvent.preventDefault()
|
||||
window.dispatchEvent(preventedEvent)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledTimes(1)
|
||||
expect(handleSelect).toHaveBeenCalledWith(option)
|
||||
|
||||
rerender({ open: false })
|
||||
fireEvent.keyDown(window, { key: '`' })
|
||||
expect(handleSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
fireEvent.keyDown(window, { key: '`' })
|
||||
expect(handleSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should run single options for element and non-element children unless the click is prevented', async () => {
|
||||
const user = userEvent.setup()
|
||||
const runSoleOption = vi.fn()
|
||||
const originalOnClick = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SingleOptionTrigger runSoleOption={runSoleOption}>
|
||||
Open directly
|
||||
</SingleOptionTrigger>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Open directly'))
|
||||
expect(runSoleOption).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<SingleOptionTrigger runSoleOption={runSoleOption}>
|
||||
<button onClick={originalOnClick}>Child trigger</button>
|
||||
</SingleOptionTrigger>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Child trigger' }))
|
||||
expect(originalOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(runSoleOption).toHaveBeenCalledTimes(2)
|
||||
|
||||
rerender(
|
||||
<SingleOptionTrigger runSoleOption={runSoleOption}>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
originalOnClick()
|
||||
}}
|
||||
>
|
||||
Prevented child
|
||||
</button>
|
||||
</SingleOptionTrigger>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Prevented child' }))
|
||||
|
||||
expect(originalOnClick).toHaveBeenCalledTimes(2)
|
||||
expect(runSoleOption).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
import type { TestRunMenuRef, TriggerOption } from '../test-run-menu'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import * as React from 'react'
|
||||
import TestRunMenu, { TriggerType } from '../test-run-menu'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
const createOption = (overrides: Partial<TriggerOption> = {}): TriggerOption => ({
|
||||
id: 'user-input',
|
||||
type: TriggerType.UserInput,
|
||||
name: 'User Input',
|
||||
icon: <span>icon</span>,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TestRunMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should run the only enabled option directly and preserve the child click handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const originalOnClick = vi.fn()
|
||||
|
||||
render(
|
||||
<TestRunMenu
|
||||
options={{
|
||||
userInput: createOption(),
|
||||
triggers: [],
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<button onClick={originalOnClick}>Run now</button>
|
||||
</TestRunMenu>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Run now' }))
|
||||
|
||||
expect(originalOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-input' }))
|
||||
})
|
||||
|
||||
it('should expose toggle via ref and select a shortcut when multiple options are available', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
const Harness = () => {
|
||||
const ref = React.useRef<TestRunMenuRef>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => ref.current?.toggle()}>Toggle via ref</button>
|
||||
<TestRunMenu
|
||||
ref={ref}
|
||||
options={{
|
||||
userInput: createOption(),
|
||||
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
|
||||
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<button>Open menu</button>
|
||||
</TestRunMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' }))
|
||||
})
|
||||
fireEvent.keyDown(window, { key: '0' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' }))
|
||||
expect(screen.getByText('~')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore disabled options in the rendered menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TestRunMenu
|
||||
options={{
|
||||
userInput: createOption({ enabled: false }),
|
||||
runAll: createOption({ id: 'run-all', type: TriggerType.All, name: 'Run All' }),
|
||||
triggers: [createOption({ id: 'trigger-1', type: TriggerType.Webhook, name: 'Webhook Trigger' })],
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
>
|
||||
<button>Open menu</button>
|
||||
</TestRunMenu>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open menu' }))
|
||||
|
||||
expect(screen.queryByText('User Input')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Webhook Trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal file
118
web/app/components/workflow/header/test-run-menu-helpers.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
|
||||
import type { TriggerOption } from './test-run-menu'
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
export type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
shortcutKey: string
|
||||
}
|
||||
|
||||
export const getNormalizedShortcutKey = (event: KeyboardEvent) => {
|
||||
return event.key === '`' ? '~' : event.key
|
||||
}
|
||||
|
||||
export const OptionRow = ({
|
||||
option,
|
||||
shortcutKey,
|
||||
onSelect,
|
||||
}: {
|
||||
option: TriggerOption
|
||||
shortcutKey?: string
|
||||
onSelect: (option: TriggerOption) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(option)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{option.icon}
|
||||
</div>
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const useShortcutMenu = ({
|
||||
open,
|
||||
shortcutMappings,
|
||||
handleSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
shortcutMappings: ShortcutMapping[]
|
||||
handleSelect: (option: TriggerOption) => void
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
|
||||
const normalizedKey = getNormalizedShortcutKey(event)
|
||||
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
|
||||
|
||||
if (mapping) {
|
||||
event.preventDefault()
|
||||
handleSelect(mapping.option)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSelect, open, shortcutMappings])
|
||||
}
|
||||
|
||||
export const SingleOptionTrigger = ({
|
||||
children,
|
||||
runSoleOption,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
runSoleOption: () => void
|
||||
}) => {
|
||||
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
}
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
|
||||
const originalOnClick = childElement.props?.onClick
|
||||
|
||||
// eslint-disable-next-line react/no-clone-element
|
||||
return cloneElement(childElement, {
|
||||
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||
if (typeof originalOnClick === 'function')
|
||||
originalOnClick(event)
|
||||
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -1,22 +1,8 @@
|
||||
import type { MouseEvent, MouseEventHandler, ReactElement } from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ShortcutMapping } from './test-run-menu-helpers'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
|
||||
|
||||
export enum TriggerType {
|
||||
UserInput = 'user_input',
|
||||
@ -52,9 +38,24 @@ export type TestRunMenuRef = {
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
shortcutKey: string
|
||||
const getEnabledOptions = (options: TestRunOptions) => {
|
||||
const flattened: TriggerOption[] = []
|
||||
|
||||
if (options.userInput)
|
||||
flattened.push(options.userInput)
|
||||
if (options.runAll)
|
||||
flattened.push(options.runAll)
|
||||
flattened.push(...options.triggers)
|
||||
|
||||
return flattened.filter(option => option.enabled !== false)
|
||||
}
|
||||
|
||||
const getMenuVisibility = (options: TestRunOptions) => {
|
||||
return {
|
||||
hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput),
|
||||
hasTriggers: options.triggers.some(trigger => trigger.enabled !== false),
|
||||
hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll),
|
||||
}
|
||||
}
|
||||
|
||||
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
|
||||
@ -76,6 +77,7 @@ const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
|
||||
return mappings
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/no-forward-ref
|
||||
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
options,
|
||||
onSelect,
|
||||
@ -97,17 +99,7 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
setOpen(false)
|
||||
}, [onSelect])
|
||||
|
||||
const enabledOptions = useMemo(() => {
|
||||
const flattened: TriggerOption[] = []
|
||||
|
||||
if (options.userInput)
|
||||
flattened.push(options.userInput)
|
||||
if (options.runAll)
|
||||
flattened.push(options.runAll)
|
||||
flattened.push(...options.triggers)
|
||||
|
||||
return flattened.filter(option => option.enabled !== false)
|
||||
}, [options])
|
||||
const enabledOptions = useMemo(() => getEnabledOptions(options), [options])
|
||||
|
||||
const hasSingleEnabledOption = enabledOptions.length === 1
|
||||
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
|
||||
@ -117,6 +109,12 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
handleSelect(soleEnabledOption)
|
||||
}, [handleSelect, soleEnabledOption])
|
||||
|
||||
useShortcutMenu({
|
||||
open,
|
||||
shortcutMappings,
|
||||
handleSelect,
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
toggle: () => {
|
||||
if (hasSingleEnabledOption) {
|
||||
@ -128,84 +126,17 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
},
|
||||
}), [hasSingleEnabledOption, runSoleOption])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
|
||||
const normalizedKey = event.key === '`' ? '~' : event.key
|
||||
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
|
||||
|
||||
if (mapping) {
|
||||
event.preventDefault()
|
||||
handleSelect(mapping.option)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSelect, open, shortcutMappings])
|
||||
|
||||
const renderOption = (option: TriggerOption) => {
|
||||
const shortcutKey = shortcutKeyById.get(option.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{option.icon}
|
||||
</div>
|
||||
<span className="ml-2 truncate">{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
|
||||
}
|
||||
|
||||
const hasUserInput = !!options.userInput && options.userInput.enabled !== false
|
||||
const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false)
|
||||
const hasRunAll = !!options.runAll && options.runAll.enabled !== false
|
||||
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options])
|
||||
|
||||
if (hasSingleEnabledOption && soleEnabledOption) {
|
||||
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
}
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
|
||||
const originalOnClick = childElement.props?.onClick
|
||||
|
||||
return cloneElement(childElement, {
|
||||
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||
if (typeof originalOnClick === 'function')
|
||||
originalOnClick(event)
|
||||
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
<SingleOptionTrigger runSoleOption={runSoleOption}>
|
||||
{children}
|
||||
</span>
|
||||
</SingleOptionTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user