refactor(web): improve app component testability and raise coverage

- refactor config-modal, create-from-dsl-modal, and sub-graph for lower complexity
- add missing tests for config-modal-body, sandbox-provider-page, and install-bundle flows
- raise targeted worktree source coverage to 90%+ for lines and statements
This commit is contained in:
CodingOnStar 2026-03-27 14:22:33 +08:00
parent edd2b040f3
commit de62fd15bd
36 changed files with 5364 additions and 1492 deletions

View File

@ -0,0 +1,342 @@
import type { ConfigModalState } from '../use-config-modal-state'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import ConfigModalBody from '../config-modal-body'
type PayloadChangeHandler = ConfigModalState['handlePayloadChange']
type PayloadValueHandler = ReturnType<PayloadChangeHandler>
let selectOnValueChange: ((value: string | null) => void) | undefined
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
children,
onValueChange,
}: {
children: React.ReactNode
onValueChange?: (value: string | null) => void
}) => {
selectOnValueChange = onValueChange
return <div>{children}</div>
},
SelectTrigger: ({
children,
className,
'aria-label': ariaLabel,
}: {
'children': React.ReactNode
'className'?: string
'aria-label'?: string
}) => (
<button type="button" role="combobox" className={className} aria-label={ariaLabel}>
{children}
</button>
),
SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>,
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => (
<div role="option" onClick={() => selectOnValueChange?.(value)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<div>
<div data-testid="code-editor">{value}</div>
<button type="button" onClick={() => onChange('{\n "type": "object"\n}')}>change json</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
default: ({
isMultiple,
onChange,
payload,
}: {
isMultiple: boolean
onChange: (payload: InputVar) => void
payload: InputVar
}) => (
<div>
<div data-testid="file-upload-setting">{String(isMultiple)}</div>
<button type="button" onClick={() => onChange({ ...payload, max_length: 9 })}>update file setting</button>
</div>
),
}))
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
value,
}: {
onChange: (files?: FileEntity[]) => void
value: FileEntity[]
}) => (
<div data-testid="file-uploader" data-count={value.length}>
<button type="button" onClick={() => onChange([{ id: 'file-1' } as FileEntity])}>upload one</button>
<button type="button" onClick={() => onChange([{ id: 'file-1' }, { id: 'file-2' }] as FileEntity[])}>upload many</button>
</div>
),
}))
const createPayloadChangeMock = () => {
const handlers = new Map<keyof InputVar, PayloadValueHandler>()
const handlePayloadChange: PayloadChangeHandler = vi.fn((key: keyof InputVar) => {
const existing = handlers.get(key)
if (existing)
return existing
const handler: PayloadValueHandler = vi.fn()
handlers.set(key, handler)
return handler
})
return {
handlePayloadChange,
handlers,
}
}
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Name',
variable: 'name',
required: false,
hide: false,
default: '',
options: [],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
allowed_file_upload_methods: [TransferMethod.remote_url],
max_length: 1,
...overrides,
})
const createState = (overrides: Partial<ConfigModalState> = {}) => {
const payloadChange = createPayloadChangeMock()
return {
state: {
checkboxDefaultSelectValue: 'false',
handleConfirm: vi.fn(),
handleJSONSchemaChange: vi.fn(),
handlePayloadChange: payloadChange.handlePayloadChange,
handleTypeChange: vi.fn(),
handleVarKeyBlur: vi.fn(),
handleVarNameChange: vi.fn(),
isStringInput: true,
jsonSchemaStr: '',
modelId: 'test-model',
modalRef: { current: null },
selectOptions: [
{ value: InputVarType.textInput, name: 'Text Input' },
{ value: InputVarType.select, name: 'Select' },
],
setTempPayload: vi.fn(),
tempPayload: createInputVar(),
...overrides,
} satisfies ConfigModalState,
payloadHandlers: payloadChange.handlers,
}
}
describe('ConfigModalBody', () => {
beforeEach(() => {
vi.clearAllMocks()
selectOnValueChange = undefined
})
// Covers the default text-input layout and footer callbacks.
describe('text input mode', () => {
it('should render text fields and trigger footer actions', () => {
const { state, payloadHandlers } = createState()
const onClose = vi.fn()
render(<ConfigModalBody state={state} onClose={onClose} />)
expect(screen.getAllByRole('textbox')).toHaveLength(3)
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'Display Name' } })
fireEvent.change(screen.getAllByRole('textbox')[2], { target: { value: 'Hello world' } })
fireEvent.click(screen.getAllByRole('checkbox')[0])
fireEvent.click(screen.getAllByRole('checkbox')[1])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(payloadHandlers.get('label')).toHaveBeenCalledWith('Display Name')
expect(payloadHandlers.get('default')).toHaveBeenCalledWith('Hello world')
expect(payloadHandlers.get('required')).toHaveBeenCalledWith(true)
expect(payloadHandlers.get('hide')).toHaveBeenCalledWith(true)
expect(state.handleConfirm).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should send updates for paragraph and number defaults', () => {
const paragraphState = createState({
tempPayload: createInputVar({
type: InputVarType.paragraph,
default: 'Paragraph value',
}),
})
const { container, unmount } = render(<ConfigModalBody state={paragraphState.state} onClose={vi.fn()} />)
fireEvent.change(container.querySelector('textarea')!, { target: { value: 'Updated paragraph' } })
expect(paragraphState.payloadHandlers.get('default')).toHaveBeenCalledWith('Updated paragraph')
unmount()
const numberState = createState({
isStringInput: false,
tempPayload: createInputVar({
type: InputVarType.number,
default: '5',
}),
})
const { container: numberContainer } = render(<ConfigModalBody state={numberState.state} onClose={vi.fn()} />)
fireEvent.change(numberContainer.querySelector('input[type="number"]')!, { target: { value: '12' } })
expect(numberState.payloadHandlers.get('default')).toHaveBeenCalledWith('12')
})
})
// Covers select-specific sections and options wiring.
describe('select mode', () => {
it('should render select options and default selector when options exist', async () => {
const { state, payloadHandlers } = createState({
isStringInput: false,
tempPayload: createInputVar({
type: InputVarType.select,
default: 'A',
options: ['A', 'B'],
}),
})
render(
<ConfigModalBody
state={state}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument()
expect(screen.getAllByText('A').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' }))
fireEvent.click(screen.getByRole('option', { name: 'B' }))
await waitFor(() => {
expect(state.handlePayloadChange).toHaveBeenCalledWith('default')
})
expect(payloadHandlers.get('default')).toHaveBeenCalledWith('B')
})
it('should convert checkbox selections into boolean defaults', async () => {
const { state, payloadHandlers } = createState({
isStringInput: false,
tempPayload: createInputVar({
type: InputVarType.checkbox,
default: undefined,
}),
})
render(<ConfigModalBody state={state} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('combobox', { name: 'appDebug.variableConfig.selectDefaultValue' }))
fireEvent.click(screen.getByRole('option', { name: 'appDebug.variableConfig.startChecked' }))
await waitFor(() => {
expect(state.handlePayloadChange).toHaveBeenCalledWith('default')
})
expect(payloadHandlers.get('default')).toHaveBeenCalledWith(true)
})
})
// Covers file and JSON branches that were split out of the original entry component.
describe('specialized sections', () => {
it('should handle file settings and uploader changes for file inputs', () => {
const singleFileState = createState({
isStringInput: false,
tempPayload: createInputVar({
type: InputVarType.singleFile,
default: undefined,
}),
})
const { unmount } = render(
<ConfigModalBody
state={singleFileState.state}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('update file setting'))
fireEvent.click(screen.getByText('upload one'))
expect(singleFileState.state.setTempPayload).toHaveBeenCalledWith(expect.objectContaining({ max_length: 9 }))
expect(singleFileState.payloadHandlers.get('default')).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1' }))
unmount()
const multiFileState = createState({
isStringInput: false,
tempPayload: createInputVar({
type: InputVarType.multiFiles,
default: [] as unknown as InputVar['default'],
}),
})
render(
<ConfigModalBody
state={multiFileState.state}
onClose={vi.fn()}
/>,
)
expect(screen.getByTestId('file-upload-setting')).toHaveTextContent('true')
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
fireEvent.click(screen.getByText('upload many'))
expect(multiFileState.payloadHandlers.get('default')).toHaveBeenCalledWith([
expect.objectContaining({ id: 'file-1' }),
expect.objectContaining({ id: 'file-2' }),
])
})
it('should render the JSON schema editor and propagate edits for json object inputs', () => {
const { state } = createState({
isStringInput: false,
jsonSchemaStr: '{\n "type": "object"\n}',
tempPayload: createInputVar({
type: InputVarType.jsonObject,
default: undefined,
}),
})
render(
<ConfigModalBody
state={state}
onClose={vi.fn()}
/>,
)
expect(screen.getByTestId('code-editor')).toHaveTextContent('"type": "object"')
fireEvent.click(screen.getByText('change json'))
expect(state.handleJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
})
})
})

View File

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import Field from '../field'
describe('ConfigModal Field', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers the required field label rendering.
describe('Rendering', () => {
it('should render the field title and children content', () => {
render(
<Field title="Field Title">
<div>Child content</div>
</Field>,
)
expect(screen.getByText('Field Title')).toBeInTheDocument()
expect(screen.getByText('Child content')).toBeInTheDocument()
})
it('should render the optional indicator when the field is optional', () => {
render(
<Field title="Optional Title" isOptional>
<div>Optional content</div>
</Field>,
)
expect(screen.getByText('Optional Title')).toBeInTheDocument()
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,97 @@
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import {
applyTypeChange,
CHECKBOX_DEFAULT_FALSE_VALUE,
CHECKBOX_DEFAULT_TRUE_VALUE,
getCheckboxDefaultSelectValue,
isJsonSchemaEmpty,
normalizeSelectDefaultValue,
parseCheckboxSelectValue,
} from '../helpers'
const createInputVar = (overrides = {}) => ({
type: InputVarType.textInput,
label: 'Name',
variable: 'name',
required: false,
hide: false,
default: '',
options: [],
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
allowed_file_upload_methods: [TransferMethod.local_file],
max_length: 1,
...overrides,
})
describe('config-modal helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers checkbox default parsing for primitive and string payload values.
describe('checkbox defaults', () => {
it('should normalize checkbox default values to selector options', () => {
expect(getCheckboxDefaultSelectValue(true)).toBe(CHECKBOX_DEFAULT_TRUE_VALUE)
expect(getCheckboxDefaultSelectValue('true')).toBe(CHECKBOX_DEFAULT_TRUE_VALUE)
expect(getCheckboxDefaultSelectValue(false)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE)
expect(getCheckboxDefaultSelectValue(undefined)).toBe(CHECKBOX_DEFAULT_FALSE_VALUE)
})
it('should convert selector values back to boolean defaults', () => {
expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_TRUE_VALUE)).toBe(true)
expect(parseCheckboxSelectValue(CHECKBOX_DEFAULT_FALSE_VALUE)).toBe(false)
})
})
// Covers select normalization and JSON schema blank detection.
describe('normalization', () => {
it('should clear empty select defaults but keep other defaults unchanged', () => {
expect(normalizeSelectDefaultValue(createInputVar({
type: InputVarType.select,
default: '',
}))).toEqual(expect.objectContaining({ default: undefined }))
expect(normalizeSelectDefaultValue(createInputVar({
type: InputVarType.textInput,
default: '',
}))).toEqual(expect.objectContaining({ default: '' }))
})
it('should detect empty JSON schema values', () => {
expect(isJsonSchemaEmpty(undefined)).toBe(true)
expect(isJsonSchemaEmpty(' ')).toBe(true)
expect(isJsonSchemaEmpty('{}')).toBe(false)
expect(isJsonSchemaEmpty({ type: 'object' })).toBe(false)
})
})
// Covers type switching behavior for select and file input payloads.
describe('type changes', () => {
it('should reset select defaults when switching to select type', () => {
const nextPayload = applyTypeChange(createInputVar({
default: 'hello',
}), InputVarType.select)
expect(nextPayload.type).toBe(InputVarType.select)
expect(nextPayload.default).toBeUndefined()
})
it('should seed upload defaults when switching to multi-file type', () => {
const nextPayload = applyTypeChange(createInputVar({
allowed_file_types: [],
allowed_file_extensions: [],
allowed_file_upload_methods: [],
max_length: 1,
}), InputVarType.multiFiles)
expect(nextPayload).toEqual(expect.objectContaining({
type: InputVarType.multiFiles,
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
max_length: 5,
}))
})
})
})

View File

@ -0,0 +1,136 @@
import type { InputVar } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
type DebugConfigValue = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
const createDebugConfigValue = (overrides: Partial<DebugConfigValue> = {}): DebugConfigValue => ({
mode: AppModeEnum.CHAT,
modelConfig: {
model_id: 'test-model',
},
...overrides,
} as DebugConfigValue)
const createAppDetail = (mode: AppModeEnum) => ({
mode,
}) as NonNullable<ReturnType<typeof useAppStore.getState>['appDetail']>
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: '',
variable: '',
required: false,
hide: false,
default: '',
...overrides,
})
const renderConfigModal = (
props: Partial<React.ComponentProps<typeof ConfigModal>> = {},
debugOverrides: Partial<DebugConfigValue> = {},
) => {
const defaultProps: React.ComponentProps<typeof ConfigModal> = {
isCreate: true,
isShow: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
payload: createInputVar(),
supportFile: false,
}
const mergedProps = {
...defaultProps,
...props,
}
return render(
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
<ConfigModal {...mergedProps} />
</DebugConfigurationContext.Provider>,
)
}
describe('ConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) })
})
// Covers the main text-input save path through the rendered modal.
describe('Save flows', () => {
it('should auto-fill the label and submit the edited payload for text inputs', () => {
const onConfirm = vi.fn()
renderConfigModal({ onConfirm })
const textboxes = screen.getAllByRole('textbox')
fireEvent.change(textboxes[0], { target: { value: 'user name' } })
fireEvent.blur(textboxes[0])
fireEvent.change(textboxes[2], { target: { value: 'hello world' } })
expect(textboxes[1]).toHaveValue('user_name')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
variable: 'user_name',
label: 'user_name',
default: 'hello world',
}), {
type: 'changeVarName',
payload: {
beforeKey: '',
afterKey: 'user_name',
},
})
})
it('should block save and show an error when label is missing', () => {
const onConfirm = vi.fn()
renderConfigModal({
onConfirm,
payload: createInputVar({
label: '',
variable: 'name',
}),
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onConfirm).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
})
})
// Covers conditional sections for non-text variable types.
describe('Conditional rendering', () => {
it('should render select-specific sections when editing a select variable', () => {
renderConfigModal({
payload: createInputVar({
type: InputVarType.select,
label: 'Category',
variable: 'category',
options: ['A', 'B'],
default: 'A',
}),
})
expect(screen.getByText('appDebug.variableConfig.options')).toBeInTheDocument()
expect(screen.getAllByText('A').length).toBeGreaterThan(0)
})
})
})

View File

@ -0,0 +1,65 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import TypeSelector from '../type-select'
const items = [
{ value: InputVarType.textInput, name: 'Text input' },
{ value: InputVarType.number, name: 'Number input' },
]
describe('ConfigModal TypeSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers closed-state rendering and current selection display.
describe('Rendering', () => {
it('should render the current selected item label', () => {
render(
<TypeSelector
value={InputVarType.textInput}
items={items}
onSelect={vi.fn()}
/>,
)
expect(screen.getByText('Text input')).toBeInTheDocument()
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
})
})
// Covers dropdown interactions and readonly behavior.
describe('User interactions', () => {
it('should call onSelect and close the list when choosing another item', () => {
const onSelect = vi.fn()
render(
<TypeSelector
value={InputVarType.textInput}
items={items}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByText('Text input'))
fireEvent.click(screen.getByText('Number input'))
expect(onSelect).toHaveBeenCalledWith(items[1])
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
})
it('should keep the list closed when readonly is true', () => {
render(
<TypeSelector
readonly
value={InputVarType.textInput}
items={items}
onSelect={vi.fn()}
/>,
)
fireEvent.click(screen.getByText('Text input'))
expect(screen.queryByText('Number input')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,376 @@
import type { ReactNode } from 'react'
import type { InputVar } from '@/app/components/workflow/types'
import { act, renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import { useConfigModalState } from '../use-config-modal-state'
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
type DebugConfigValue = React.ComponentProps<typeof DebugConfigurationContext.Provider>['value']
const createDebugConfigValue = (overrides: Partial<DebugConfigValue> = {}): DebugConfigValue => ({
mode: AppModeEnum.CHAT,
modelConfig: {
model_id: 'test-model',
},
...overrides,
} as DebugConfigValue)
const createAppDetail = (mode: AppModeEnum) => ({
mode,
}) as NonNullable<ReturnType<typeof useAppStore.getState>['appDetail']>
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
type: InputVarType.textInput,
label: 'Name',
variable: 'name',
required: false,
hide: false,
default: '',
...overrides,
})
const renderConfigModalHook = (
props: Partial<Parameters<typeof useConfigModalState>[0]> = {},
debugOverrides: Partial<DebugConfigValue> = {},
) => {
const wrapper = ({ children }: { children: ReactNode }) => (
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
{children}
</DebugConfigurationContext.Provider>
)
return renderHook(() => useConfigModalState({
isShow: true,
onConfirm: vi.fn(),
payload: createInputVar(),
supportFile: false,
...props,
}), { wrapper })
}
describe('useConfigModalState', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.CHAT) })
})
// Covers app-mode dependent type options.
describe('select options', () => {
it('should expose JSON and file types for advanced apps with file support', () => {
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) })
const { result } = renderConfigModalHook({
supportFile: true,
})
const optionValues = result.current.selectOptions.map(option => option.value)
expect(optionValues).toContain(InputVarType.singleFile)
expect(optionValues).toContain(InputVarType.multiFiles)
expect(optionValues).toContain(InputVarType.jsonObject)
})
})
// Covers variable input normalization and label auto-fill.
describe('variable editing', () => {
it('should replace spaces with underscores and copy the value into label on blur', () => {
const { result } = renderConfigModalHook({
payload: createInputVar({
label: '',
variable: '',
}),
})
const input = document.createElement('input')
input.value = 'user name'
input.setSelectionRange(0, 0)
act(() => {
result.current.handleVarNameChange({ target: input } as React.ChangeEvent<HTMLInputElement>)
})
expect(input.value).toBe('user_name')
expect(result.current.tempPayload.variable).toBe('user_name')
act(() => {
result.current.handleVarKeyBlur({ target: input } as React.FocusEvent<HTMLInputElement>)
})
expect(result.current.tempPayload.label).toBe('user_name')
})
it('should reject invalid variable names and avoid overwriting an existing label on blur', () => {
const { result } = renderConfigModalHook({
payload: createInputVar({
label: 'Existing label',
variable: 'name',
}),
})
const invalidInput = document.createElement('input')
invalidInput.value = '1bad'
act(() => {
result.current.handleVarNameChange({ target: invalidInput } as React.ChangeEvent<HTMLInputElement>)
})
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber'))
expect(result.current.tempPayload.variable).toBe('name')
const blurInput = document.createElement('input')
blurInput.value = 'updated_name'
act(() => {
result.current.handleVarKeyBlur({ target: blurInput } as React.FocusEvent<HTMLInputElement>)
})
expect(result.current.tempPayload.label).toBe('Existing label')
})
})
// Covers payload mutation helpers and schema parsing.
describe('state helpers', () => {
it('should clear invalid select defaults and normalize json schema edits', () => {
const { result } = renderConfigModalHook({
payload: createInputVar({
type: InputVarType.select,
default: 'keep-me',
options: ['keep-me', 'next'],
}),
})
act(() => {
result.current.handlePayloadChange('options')(['next'])
})
expect(result.current.tempPayload.options).toEqual(['next'])
expect(result.current.tempPayload.default).toBeUndefined()
act(() => {
result.current.handleJSONSchemaChange('{"type":"object"}')
})
expect(result.current.tempPayload.json_schema).toBe('{\n "type": "object"\n}')
act(() => {
result.current.handleJSONSchemaChange(' ')
})
expect(result.current.tempPayload.json_schema).toBeUndefined()
expect(result.current.handleJSONSchemaChange('{invalid')).toBeNull()
})
it('should update the payload when changing types and expose file/json options for advanced apps', () => {
useAppStore.setState({ appDetail: createAppDetail(AppModeEnum.WORKFLOW) })
const { result } = renderConfigModalHook({
supportFile: true,
})
act(() => {
result.current.handleTypeChange({ value: InputVarType.singleFile, name: 'Single File' })
})
expect(result.current.tempPayload.type).toBe(InputVarType.singleFile)
expect(result.current.selectOptions.map(option => option.value)).toEqual(expect.arrayContaining([
InputVarType.singleFile,
InputVarType.multiFiles,
InputVarType.jsonObject,
]))
})
})
// Covers confirm validation and metadata generation.
describe('confirm flows', () => {
it('should block duplicate select options and show an error toast', () => {
const onConfirm = vi.fn()
const { result } = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.select,
options: ['first', 'first'],
}),
})
act(() => {
result.current.handleConfirm()
})
expect(onConfirm).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.optionRepeat')
})
it('should block missing labels, invalid variable names, and incomplete file settings', () => {
const onConfirm = vi.fn()
const { result, rerender } = renderConfigModalHook({
onConfirm,
payload: createInputVar({
variable: '1bad',
label: 'Valid label',
}),
})
act(() => {
result.current.handleConfirm()
})
expect(onConfirm).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('appDebug.varKeyError.notStartWithNumber'))
rerender()
const labelless = renderConfigModalHook({
onConfirm,
payload: createInputVar({
label: '',
}),
})
act(() => {
labelless.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
const noFileTypes = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.singleFile,
label: 'File input',
default: undefined,
allowed_file_types: [],
}),
})
act(() => {
noFileTypes.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired'))
const customWithoutExtensions = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.singleFile,
label: 'File input',
default: undefined,
allowed_file_types: [SupportUploadFileTypes.custom],
allowed_file_extensions: [],
}),
})
act(() => {
customWithoutExtensions.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('workflow.errorMsg.fieldRequired'))
})
it('should validate select and json object edge cases before saving', () => {
const onConfirm = vi.fn()
const emptyOptions = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.select,
options: [],
}),
})
act(() => {
emptyOptions.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.atLeastOneOption')
const invalidJson = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.jsonObject,
label: 'JSON payload',
json_schema: '{invalid}',
}),
})
act(() => {
invalidJson.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaInvalid')
const nonObjectJson = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.jsonObject,
label: 'JSON payload',
json_schema: '{"type":"array"}',
}),
})
act(() => {
nonObjectJson.result.current.handleConfirm()
})
expect(toast.error).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.jsonSchemaMustBeObject')
})
it('should omit empty JSON schema and include rename metadata when saving', () => {
const onConfirm = vi.fn()
const { result } = renderConfigModalHook({
onConfirm,
payload: createInputVar({
type: InputVarType.jsonObject,
json_schema: ' ',
variable: 'old_name',
label: 'JSON payload',
}),
})
act(() => {
result.current.handlePayloadChange('variable')('new_name')
})
act(() => {
result.current.handleConfirm()
})
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
variable: 'new_name',
json_schema: undefined,
}), {
type: 'changeVarName',
payload: {
beforeKey: 'old_name',
afterKey: 'new_name',
},
})
})
it('should save without rename metadata when the variable name does not change', () => {
const onConfirm = vi.fn()
const { result } = renderConfigModalHook({
onConfirm,
payload: createInputVar({
variable: 'same_name',
label: 'Same name',
}),
})
act(() => {
result.current.handleConfirm()
})
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
variable: 'same_name',
}), undefined)
})
})
})

View File

@ -0,0 +1,297 @@
'use client'
import type { FC } from 'react'
import type { ConfigModalState } from './use-config-modal-state'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder } from './config'
import Field from './field'
import {
CHECKBOX_DEFAULT_FALSE_VALUE,
CHECKBOX_DEFAULT_TRUE_VALUE,
FILE_INPUT_TYPES,
parseCheckboxSelectValue,
TEXT_MAX_LENGTH,
} from './helpers'
import TypeSelector from './type-select'
type Props = {
onClose: () => void
state: ConfigModalState
}
type SelectOption = {
value: string
name: string
}
type InlineSelectProps = {
value: string
items: SelectOption[]
placeholder: string
onChange: (value: string) => void
}
const InlineSelect = ({
value,
items,
placeholder,
onChange,
}: InlineSelectProps) => {
return (
<Select
value={value}
onValueChange={(nextValue) => {
if (nextValue !== null)
onChange(nextValue)
}}
>
<SelectTrigger aria-label={placeholder} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px]">
{items.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const EMPTY_DEFAULT_VALUE = '__empty-default__'
const ConfigModalBody: FC<Props> = ({
state,
onClose,
}) => {
const { t } = useTranslation()
const {
checkboxDefaultSelectValue,
handleConfirm,
handleJSONSchemaChange,
handlePayloadChange,
handleTypeChange,
handleVarKeyBlur,
handleVarNameChange,
isStringInput,
jsonSchemaStr,
modelId,
modalRef,
selectOptions,
setTempPayload,
tempPayload,
} = state
const { type, label, max_length, options, variable } = tempPayload
const isFileInput = FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])
return (
<>
<div className="mb-8" ref={modalRef} tabIndex={-1}>
<div className="space-y-2">
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
</Field>
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
<Input
value={variable}
onBlur={handleVarKeyBlur}
onChange={handleVarNameChange}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
<Input
value={label as string}
onChange={event => handlePayloadChange('label')(event.target.value)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
{isStringInput && (
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
<ConfigString
maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Number.POSITIVE_INFINITY}
modelId={modelId}
value={max_length}
onChange={handlePayloadChange('max_length') as (value: number | undefined) => void}
/>
</Field>
)}
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
value={String(tempPayload.default ?? '')}
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.number && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
onChange={event => handlePayloadChange('default')(event.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.checkbox && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<InlineSelect
value={checkboxDefaultSelectValue}
items={[
{
value: CHECKBOX_DEFAULT_TRUE_VALUE,
name: t('variableConfig.startChecked', { ns: 'appDebug' }),
},
{
value: CHECKBOX_DEFAULT_FALSE_VALUE,
name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }),
},
]}
onChange={value => handlePayloadChange('default')(parseCheckboxSelectValue(value))}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
/>
</Field>
)}
{type === InputVarType.select && (
<>
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
<ConfigSelect
options={options || []}
onChange={handlePayloadChange('options') as (value: string[]) => void}
/>
</Field>
{options && options.length > 0 && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<InlineSelect
value={String(tempPayload.default || EMPTY_DEFAULT_VALUE)}
items={[
{ value: EMPTY_DEFAULT_VALUE, name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
...options
.filter(option => option.trim() !== '')
.map(option => ({ value: option, name: option })),
]}
onChange={value => handlePayloadChange('default')(value === EMPTY_DEFAULT_VALUE ? undefined : value)}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
/>
</Field>
)}
</>
)}
{isFileInput && (
<>
<FileUploadSetting
isMultiple={type === InputVarType.multiFiles}
onChange={payload => setTempPayload(payload as InputVar)}
payload={tempPayload as UploadFileSetting}
/>
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<FileUploaderInAttachmentWrapper
fileConfig={{
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
}}
onChange={(files) => {
if (type === InputVarType.singleFile)
handlePayloadChange('default')(files?.[0] || undefined)
else
handlePayloadChange('default')(files || undefined)
}}
value={(
type === InputVarType.singleFile
? (tempPayload.default ? [tempPayload.default] : [])
: (tempPayload.default || [])
) as unknown as FileEntity[]}
/>
</Field>
</>
)}
{type === InputVarType.jsonObject && (
<Field isOptional title={t('variableConfig.jsonSchema', { ns: 'appDebug' })}>
<CodeEditor
className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
language={CodeLanguage.json}
noWrapper
onChange={handleJSONSchemaChange}
placeholder={<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>}
value={jsonSchemaStr}
/>
</Field>
)}
<div className="!mt-5 flex h-6 items-center space-x-2">
<Checkbox
checked={tempPayload.required}
disabled={tempPayload.hide}
onCheck={() => handlePayloadChange('required')(!tempPayload.required)}
/>
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</div>
<div className="!mt-5 flex h-6 items-center space-x-2">
<Checkbox
checked={tempPayload.hide}
disabled={tempPayload.required}
onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)}
/>
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
</div>
</div>
</div>
<ModalFoot
onCancel={onClose}
onConfirm={handleConfirm}
/>
</>
)
}
export default React.memo(ConfigModalBody)

View File

@ -0,0 +1,59 @@
'use client'
import type { InputVar } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import { InputVarType } from '@/app/components/workflow/types'
export const TEXT_MAX_LENGTH = 256
export const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
export const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
export const FILE_INPUT_TYPES = [InputVarType.singleFile, InputVarType.multiFiles] as const
export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boolean) => {
if (typeof value === 'boolean')
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
if (typeof value === 'string')
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
return CHECKBOX_DEFAULT_FALSE_VALUE
}
export const parseCheckboxSelectValue = (value: string) => value === CHECKBOX_DEFAULT_TRUE_VALUE
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
if (inputVar.type === InputVarType.select && inputVar.default === '')
return { ...inputVar, default: undefined }
return inputVar
}
export const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined)
return true
if (typeof value !== 'string')
return false
return value.trim() === ''
}
export const applyTypeChange = (payload: InputVar, type: InputVarType) => {
return produce(payload, (draft) => {
draft.type = type
if (type === InputVarType.select)
draft.default = undefined
if (FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])) {
Object.entries(DEFAULT_FILE_UPLOAD_SETTING).forEach(([key, value]) => {
if (key !== 'max_length')
Reflect.set(draft, key, value)
})
if (type === InputVarType.multiFiles)
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
}
})
}

View File

@ -1,56 +1,11 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import type { Item as SelectItem } from './type-select'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
import { produce } from 'immer'
import type { FC } from 'react'
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Checkbox from '@/app/components/base/checkbox'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { toast } from '@/app/components/base/ui/toast'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, TransferMethod } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder } from './config'
import Field from './field'
import TypeSelector from './type-select'
const TEXT_MAX_LENGTH = 256
const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
const getCheckboxDefaultSelectValue = (value: InputVar['default']) => {
if (typeof value === 'boolean')
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
if (typeof value === 'string')
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
return CHECKBOX_DEFAULT_FALSE_VALUE
}
const parseCheckboxSelectValue = (value: string) =>
value === CHECKBOX_DEFAULT_TRUE_VALUE
const normalizeSelectDefaultValue = (inputVar: InputVar) => {
if (inputVar.type === InputVarType.select && inputVar.default === '')
return { ...inputVar, default: undefined }
return inputVar
}
import ConfigModalBody from './config-modal-body'
import { useConfigModalState } from './use-config-modal-state'
export type IConfigModalProps = {
isCreate?: boolean
@ -70,257 +25,13 @@ const ConfigModal: FC<IConfigModalProps> = ({
onConfirm,
supportFile,
}) => {
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
const { type, label, variable, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject
if (!isJsonObject || !tempPayload.json_schema)
return ''
try {
return tempPayload.json_schema
}
catch {
return ''
}
}, [tempPayload.json_schema])
useEffect(() => {
// To fix the first input element auto focus, then directly close modal will raise error
if (isShow)
modalRef.current?.focus()
}, [isShow])
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
if (!isValid) {
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }))
return false
}
return true
}, [t])
const handlePayloadChange = useCallback((key: string) => {
return (value: any) => {
setTempPayload((prev) => {
const newPayload = {
...prev,
[key]: value,
}
// Clear default value if modified options no longer include current default
if (key === 'options' && prev.default) {
const optionsArray = Array.isArray(value) ? value : []
if (!optionsArray.includes(prev.default))
newPayload.default = undefined
}
return newPayload
})
}
}, [])
const handleJSONSchemaChange = useCallback((value: string) => {
const isEmpty = value == null || value.trim() === ''
if (isEmpty) {
handlePayloadChange('json_schema')(undefined)
return null
}
try {
const v = JSON.parse(value)
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
}
catch {
return null
}
}, [handlePayloadChange])
const selectOptions: SelectItem[] = [
{
name: t('variableConfig.text-input', { ns: 'appDebug' }),
value: InputVarType.textInput,
},
{
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t('variableConfig.select', { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t('variableConfig.number', { ns: 'appDebug' }),
value: InputVarType.number,
},
{
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
value: InputVarType.checkbox,
},
...(supportFile
? [
{
name: t('variableConfig.single-file', { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
: []),
...((!isBasicApp)
? [{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
}]
: []),
]
const handleTypeChange = useCallback((item: SelectItem) => {
const type = item.value as InputVarType
const newPayload = produce(tempPayload, (draft) => {
draft.type = type
if (type === InputVarType.select)
draft.default = undefined
if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
)) {
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
if (key !== 'max_length')
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
})
if (type === InputVarType.multiFiles)
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
}
})
setTempPayload(newPayload)
}, [tempPayload])
const handleVarKeyBlur = useCallback((e: any) => {
const varName = e.target.value
if (!checkVariableName(varName, true) || tempPayload.label)
return
setTempPayload((prev) => {
return {
...prev,
label: varName,
}
})
}, [checkVariableName, tempPayload.label])
const handleVarNameChange = useCallback((e: ChangeEvent<any>) => {
replaceSpaceWithUnderscoreInVarNameInput(e.target)
const value = e.target.value
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
if (!isValid) {
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }))
return
}
handlePayloadChange('variable')(e.target.value)
}, [handlePayloadChange, t])
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined) {
return true
}
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
return trimmed === ''
}
const handleConfirm = () => {
const jsonSchemaValue = tempPayload.json_schema
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
// remove the `json_schema` field from the payload by setting its value to `undefined`.
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
type: ChangeType.changeVarName,
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
}
const isVariableNameValid = checkVariableName(tempPayload.variable)
if (!isVariableNameValid)
return
if (!tempPayload.label) {
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
return
}
if (isStringInput || type === InputVarType.number) {
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.select) {
if (options?.length === 0) {
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
return
}
const obj: Record<string, boolean> = {}
let hasRepeatedItem = false
options?.forEach((o) => {
if (obj[o]) {
hasRepeatedItem = true
return
}
obj[o] = true
})
if (hasRepeatedItem) {
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
return
}
onConfirm(payloadToSave, moreInfo)
}
else if (([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
)) {
if (tempPayload.allowed_file_types?.length === 0) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
toast.error(errorMessages)
return
}
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.custom.name', { ns: 'appDebug' }) })
toast.error(errorMessages)
return
}
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.jsonObject) {
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
return
}
}
catch {
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
return
}
}
onConfirm(payloadToSave, moreInfo)
}
else {
onConfirm(payloadToSave, moreInfo)
}
}
const modalState = useConfigModalState({
payload,
isShow,
onConfirm,
supportFile,
})
return (
<Modal
@ -328,173 +39,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
isShow={isShow}
onClose={onClose}
>
<div className="mb-8" ref={modalRef} tabIndex={-1}>
<div className="space-y-2">
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
</Field>
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
<Input
value={variable}
onChange={handleVarNameChange}
onBlur={handleVarKeyBlur}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
<Input
value={label as string}
onChange={e => handlePayloadChange('label')(e.target.value)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
{isStringInput && (
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
<ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelConfig.model_id} value={max_length} onChange={handlePayloadChange('max_length')} />
</Field>
)}
{/* Default value for text input */}
{type === InputVarType.textInput && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
value={tempPayload.default || ''}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{/* Default value for paragraph */}
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
value={String(tempPayload.default ?? '')}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{/* Default value for number input */}
{type === InputVarType.number && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Input
type="number"
value={tempPayload.default || ''}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
/>
</Field>
)}
{type === InputVarType.checkbox && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<SimpleSelect
className="w-full"
optionWrapClassName="max-h-[140px] overflow-y-auto"
items={[
{ value: CHECKBOX_DEFAULT_TRUE_VALUE, name: t('variableConfig.startChecked', { ns: 'appDebug' }) },
{ value: CHECKBOX_DEFAULT_FALSE_VALUE, name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }) },
]}
defaultValue={checkboxDefaultSelectValue}
onSelect={item => handlePayloadChange('default')(parseCheckboxSelectValue(String(item.value)))}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
allowSearch={false}
/>
</Field>
)}
{type === InputVarType.select && (
<>
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
</Field>
{options && options.length > 0 && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<SimpleSelect
key={`default-select-${options.join('-')}`}
className="w-full"
optionWrapClassName="max-h-[140px] overflow-y-auto"
items={[
{ value: '', name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
...options.filter(opt => opt.trim() !== '').map(option => ({
value: option,
name: option,
})),
]}
defaultValue={tempPayload.default || ''}
onSelect={item => handlePayloadChange('default')(item.value === '' ? undefined : item.value)}
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
allowSearch={false}
/>
</Field>
)}
</>
)}
{([InputVarType.singleFile, InputVarType.multiFiles] as const).includes(
type as typeof InputVarType.singleFile | typeof InputVarType.multiFiles,
) && (
<>
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
isMultiple={type === InputVarType.multiFiles}
/>
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<FileUploaderInAttachmentWrapper
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
onChange={(files) => {
if (type === InputVarType.singleFile)
handlePayloadChange('default')(files?.[0] || undefined)
else
handlePayloadChange('default')(files || undefined)
}}
fileConfig={{
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
}}
/>
</Field>
</>
)}
{type === InputVarType.jsonObject && (
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
<CodeEditor
language={CodeLanguage.json}
value={jsonSchemaStr}
onChange={handleJSONSchemaChange}
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>
}
/>
</Field>
)}
<div className="!mt-5 flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</div>
<div className="!mt-5 flex h-6 items-center space-x-2">
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
<span className="text-text-secondary system-sm-semibold">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
</div>
</div>
</div>
<ModalFoot
onConfirm={handleConfirm}
onCancel={onClose}
/>
<ConfigModalBody state={modalState} onClose={onClose} />
</Modal>
)
}

View File

@ -0,0 +1,299 @@
'use client'
import type { ChangeEvent, Dispatch, FocusEvent, RefObject, SetStateAction } from 'react'
import type { IConfigModalProps } from './index'
import type { Item as SelectItem } from './type-select'
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import {
applyTypeChange,
FILE_INPUT_TYPES,
getCheckboxDefaultSelectValue,
isJsonSchemaEmpty,
normalizeSelectDefaultValue,
} from './helpers'
type ConfigModalStateParams = Pick<IConfigModalProps, 'isShow' | 'onConfirm' | 'payload' | 'supportFile'>
type PayloadChangeHandler = (key: keyof InputVar) => (value: unknown) => void
export type ConfigModalState = {
checkboxDefaultSelectValue: string
handleConfirm: () => void
handleJSONSchemaChange: (value: string) => null | undefined
handlePayloadChange: PayloadChangeHandler
handleTypeChange: (item: SelectItem) => void
handleVarKeyBlur: (event: FocusEvent<HTMLInputElement>) => void
handleVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
isStringInput: boolean
jsonSchemaStr: string
modelId: string
modalRef: RefObject<HTMLDivElement | null>
selectOptions: SelectItem[]
setTempPayload: Dispatch<SetStateAction<InputVar>>
tempPayload: InputVar
}
export const useConfigModalState = ({
payload,
isShow,
onConfirm,
supportFile,
}: ConfigModalStateParams): ConfigModalState => {
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload ?? getNewVarInWorkflow('')))
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const { type } = tempPayload
useEffect(() => {
if (isShow)
modalRef.current?.focus()
}, [isShow])
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
const jsonSchemaStr = useMemo(() => {
if (type !== InputVarType.jsonObject || !tempPayload.json_schema)
return ''
return typeof tempPayload.json_schema === 'string'
? tempPayload.json_schema
: JSON.stringify(tempPayload.json_schema, null, 2)
}, [tempPayload.json_schema, type])
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
if (!isValid) {
toast.error(t(`varKeyError.${errorMessageKey}`, {
ns: 'appDebug',
key: t('variableConfig.varName', { ns: 'appDebug' }),
}))
return false
}
return true
}, [t])
const handlePayloadChange = useCallback<PayloadChangeHandler>((key: keyof InputVar) => {
return (value: unknown) => {
setTempPayload((previousPayload) => {
const nextPayload = {
...previousPayload,
[key]: value,
} as InputVar
if (key === 'options' && previousPayload.default) {
const options = Array.isArray(value) ? value : []
if (!options.includes(previousPayload.default))
nextPayload.default = undefined
}
return nextPayload
})
}
}, [])
const handleJSONSchemaChange = useCallback((value: string) => {
if (value.trim() === '') {
handlePayloadChange('json_schema')(undefined)
return null
}
try {
const parsedSchema = JSON.parse(value)
handlePayloadChange('json_schema')(JSON.stringify(parsedSchema, null, 2))
}
catch {
return null
}
}, [handlePayloadChange])
const selectOptions = useMemo<SelectItem[]>(() => ([
{
name: t('variableConfig.text-input', { ns: 'appDebug' }),
value: InputVarType.textInput,
},
{
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t('variableConfig.select', { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t('variableConfig.number', { ns: 'appDebug' }),
value: InputVarType.number,
},
{
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
value: InputVarType.checkbox,
},
...(supportFile
? [
{
name: t('variableConfig.single-file', { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
: []),
...(!isBasicApp
? [
{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
},
]
: []),
]), [isBasicApp, supportFile, t])
const handleTypeChange = useCallback((item: SelectItem) => {
setTempPayload(previousPayload => applyTypeChange(previousPayload, item.value))
}, [])
const handleVarKeyBlur = useCallback((event: FocusEvent<HTMLInputElement>) => {
const variableName = event.target.value
if (!checkVariableName(variableName, true) || tempPayload.label)
return
setTempPayload(previousPayload => ({
...previousPayload,
label: variableName,
}))
}, [checkVariableName, tempPayload.label])
const handleVarNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
replaceSpaceWithUnderscoreInVarNameInput(event.target)
const value = event.target.value
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
if (!isValid) {
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }))
return
}
handlePayloadChange('variable')(value)
}, [handlePayloadChange, t])
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const buildMoreInfo = useCallback((): MoreInfo | undefined => {
if (tempPayload.variable === payload?.variable)
return undefined
return {
type: ChangeType.changeVarName,
payload: {
beforeKey: payload?.variable || '',
afterKey: tempPayload.variable,
},
}
}, [payload?.variable, tempPayload.variable])
const validateBeforeConfirm = useCallback(() => {
if (!checkVariableName(tempPayload.variable))
return false
if (!tempPayload.label) {
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
return false
}
if (type === InputVarType.select) {
if (!tempPayload.options?.length) {
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
return false
}
const duplicatedOption = tempPayload.options.find((option, index) => tempPayload.options?.indexOf(option) !== index)
if (duplicatedOption) {
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
return false
}
}
if (FILE_INPUT_TYPES.includes(type as typeof FILE_INPUT_TYPES[number])) {
if (!tempPayload.allowed_file_types?.length) {
toast.error(t('errorMsg.fieldRequired', {
ns: 'workflow',
field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }),
}))
return false
}
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
toast.error(t('errorMsg.fieldRequired', {
ns: 'workflow',
field: t('variableConfig.file.custom.name', { ns: 'appDebug' }),
}))
return false
}
}
const normalizedJsonSchema = isJsonSchemaEmpty(tempPayload.json_schema)
? undefined
: tempPayload.json_schema
if (type === InputVarType.jsonObject && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
return false
}
}
catch {
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
return false
}
}
return true
}, [checkVariableName, t, tempPayload, type])
const handleConfirm = useCallback(() => {
if (!validateBeforeConfirm())
return
const payloadToSave = type === InputVarType.jsonObject && isJsonSchemaEmpty(tempPayload.json_schema)
? { ...tempPayload, json_schema: undefined }
: tempPayload
onConfirm(payloadToSave, buildMoreInfo())
}, [buildMoreInfo, onConfirm, tempPayload, type, validateBeforeConfirm])
return {
checkboxDefaultSelectValue,
handleConfirm,
handleJSONSchemaChange,
handlePayloadChange,
handleTypeChange,
handleVarKeyBlur,
handleVarNameChange,
isStringInput,
jsonSchemaStr,
modelId: modelConfig.model_id,
modalRef,
selectOptions,
setTempPayload,
tempPayload,
}
}

View File

@ -0,0 +1,233 @@
import type { PromptItem } from '@/models/debug'
import { act, renderHook } from '@testing-library/react'
import { PromptMode } from '@/models/debug'
import { fetchPromptTemplate } from '@/service/debug'
import { AppModeEnum, ModelModeType } from '@/types/app'
import useAdvancedPromptConfig from '../use-advanced-prompt-config'
const mockFetchPromptTemplate = vi.mocked(fetchPromptTemplate)
vi.mock('@/service/debug', () => ({
fetchPromptTemplate: vi.fn(),
}))
describe('useAdvancedPromptConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchPromptTemplate.mockResolvedValue({
chat_prompt_config: {
prompt: [
{ text: 'context {{#pre_prompt#}}' },
],
},
completion_prompt_config: {
prompt: {
text: 'completion {{#pre_prompt#}}',
},
conversation_histories_role: {
user_prefix: 'User',
assistant_prefix: 'Assistant',
},
},
stop: ['END'],
} as Awaited<ReturnType<typeof fetchPromptTemplate>>)
})
const renderAdvancedPromptHook = (overrides: Partial<Parameters<typeof useAdvancedPromptConfig>[0]> = {}) => {
const setCompletionParams = vi.fn()
const setStop = vi.fn()
const onUserChangedPrompt = vi.fn()
const hook = renderHook(() => useAdvancedPromptConfig({
appMode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
modelName: 'gpt-4o',
promptMode: PromptMode.advanced,
prePrompt: 'prefill',
onUserChangedPrompt,
hasSetDataSet: false,
completionParams: {},
setCompletionParams,
setStop,
...overrides,
}))
return {
...hook,
onUserChangedPrompt,
setCompletionParams,
setStop,
}
}
// Covers prompt state setters for advanced mode.
describe('prompt state', () => {
it('should update the current advanced prompt and notify when the user changed it', () => {
const prompt: PromptItem[] = [{ text: 'updated prompt' }]
const { result, onUserChangedPrompt } = renderAdvancedPromptHook()
act(() => {
result.current.setCurrentAdvancedPrompt(prompt, true)
})
expect(result.current.currentAdvancedPrompt).toEqual(prompt)
expect(onUserChangedPrompt).toHaveBeenCalledTimes(1)
})
it('should update completion prompts, history role, and block status in completion mode', () => {
const { result } = renderAdvancedPromptHook({
modelModeType: ModelModeType.completion,
})
act(() => {
result.current.setCurrentAdvancedPrompt({ text: '{{#context#}}{{#histories#}}{{#query#}}' }, true)
})
act(() => {
result.current.setConversationHistoriesRole({
user_prefix: 'User:',
assistant_prefix: 'Assistant:',
})
})
expect(result.current.completionPromptConfig.prompt.text).toBe('{{#context#}}{{#histories#}}{{#query#}}')
expect(result.current.completionPromptConfig.conversation_histories_role).toEqual({
user_prefix: 'User:',
assistant_prefix: 'Assistant:',
})
expect(result.current.hasSetBlockStatus).toEqual({
context: true,
history: true,
query: true,
})
})
it('should no-op advanced prompt setters when the prompt mode is simple', () => {
const { result, onUserChangedPrompt } = renderAdvancedPromptHook({
promptMode: PromptMode.simple,
prePrompt: '{{#context#}}',
})
act(() => {
result.current.setCurrentAdvancedPrompt([{ text: 'ignored' }], true)
})
expect(result.current.currentAdvancedPrompt).toEqual([])
expect(result.current.hasSetBlockStatus).toEqual({
context: true,
history: false,
query: false,
})
expect(onUserChangedPrompt).not.toHaveBeenCalled()
})
})
// Covers default prompt migration for simple-mode chat apps.
describe('default prompt migration', () => {
it('should fetch and hydrate the default chat prompt when upgrading from simple mode', async () => {
const { result } = renderAdvancedPromptHook({
promptMode: PromptMode.simple,
})
await act(async () => {
await result.current.migrateToDefaultPrompt()
})
expect(mockFetchPromptTemplate).toHaveBeenCalledWith({
appMode: AppModeEnum.CHAT,
mode: ModelModeType.chat,
modelName: 'gpt-4o',
hasSetDataSet: false,
})
expect(result.current.chatPromptConfig.prompt[0].text).toBe('context prefill')
})
it('should hydrate completion prompts when upgrading a simple completion app', async () => {
const { result, setCompletionParams } = renderAdvancedPromptHook({
promptMode: PromptMode.simple,
modelModeType: ModelModeType.completion,
completionParams: { temperature: 0.7 },
})
await act(async () => {
await result.current.migrateToDefaultPrompt()
})
expect(result.current.completionPromptConfig.prompt.text).toBe('completion prefill')
expect(setCompletionParams).toHaveBeenCalledWith({
temperature: 0.7,
stop: ['END'],
})
})
it('should migrate to completion prompts and propagate stop words when switching modes', async () => {
const { result, setCompletionParams, setStop } = renderAdvancedPromptHook({
modelModeType: ModelModeType.chat,
appMode: AppModeEnum.ADVANCED_CHAT,
completionParams: { stop: [] },
})
await act(async () => {
await result.current.migrateToDefaultPrompt(true, ModelModeType.completion)
})
expect(result.current.completionPromptConfig.prompt.text).toBe('completion prefill')
expect(setCompletionParams).toHaveBeenCalledWith({
stop: ['END'],
})
expect(setStop).toHaveBeenCalledWith(['END'])
})
it('should preserve existing completion prompt text and stop words when migrating advanced prompts', async () => {
const { result, setCompletionParams, setStop } = renderAdvancedPromptHook({
modelModeType: ModelModeType.chat,
appMode: AppModeEnum.AGENT_CHAT,
completionParams: { stop: ['KEEP'] },
})
act(() => {
result.current.setCompletionPromptConfig({
prompt: { text: 'kept {{#pre_prompt#}}' },
conversation_histories_role: {
user_prefix: 'User',
assistant_prefix: 'Assistant',
},
})
})
await act(async () => {
await result.current.migrateToDefaultPrompt(true, ModelModeType.completion)
})
expect(result.current.completionPromptConfig.prompt.text).toBe('kept prefill')
expect(result.current.completionPromptConfig.conversation_histories_role).toEqual({
user_prefix: 'User',
assistant_prefix: 'Assistant',
})
expect(setCompletionParams).not.toHaveBeenCalled()
expect(setStop).toHaveBeenCalledWith(['END'])
})
it('should migrate advanced prompts back to chat mode and skip migration when app mode is missing', async () => {
const { result } = renderAdvancedPromptHook({
appMode: undefined,
})
await act(async () => {
await result.current.migrateToDefaultPrompt()
})
expect(mockFetchPromptTemplate).not.toHaveBeenCalled()
const advancedResult = renderAdvancedPromptHook({
modelModeType: ModelModeType.completion,
})
await act(async () => {
await advancedResult.result.current.migrateToDefaultPrompt(true, ModelModeType.chat)
})
expect(advancedResult.result.current.chatPromptConfig.prompt[0].text).toBe('context prefill')
})
})
})

View File

@ -0,0 +1,171 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import DSLConfirmModal from '../dsl-confirm-modal'
const mockPush = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
const mockImportAppBundle = vi.fn()
const mockGetRedirection = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
vi.mock('@/service/apps', () => ({
importAppBundle: (...args: unknown[]) => mockImportAppBundle(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
}))
describe('DSLConfirmModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
})
it('should call onConfirm directly for non-zip files', () => {
const onConfirm = vi.fn()
render(
<DSLConfirmModal
file={new File(['yaml'], 'demo.yml', { type: 'text/yaml' })}
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(mockImportAppBundle).not.toHaveBeenCalled()
})
it('should import zip bundles and redirect on success', async () => {
mockImportAppBundle.mockResolvedValue({
status: 'completed',
app_id: 'app-1',
app_mode: 'chat',
})
const onCancel = vi.fn()
const onSuccess = vi.fn()
render(
<DSLConfirmModal
file={new File(['zip'], 'demo.zip', { type: 'application/zip' })}
onCancel={onCancel}
onConfirm={vi.fn()}
onSuccess={onSuccess}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(mockImportAppBundle).toHaveBeenCalledTimes(1)
})
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
expect(mockGetRedirection).toHaveBeenCalledWith(true, { id: 'app-1', mode: 'chat' }, mockPush)
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should keep the confirm button disabled when requested', () => {
render(
<DSLConfirmModal
confirmDisabled
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'app.newApp.Confirm' })).toBeDisabled()
})
it('should show warning and failure toasts for zip bundle edge cases', async () => {
mockImportAppBundle.mockResolvedValueOnce({
status: 'completed-with-warnings',
app_id: 'app-2',
app_mode: 'workflow',
}).mockResolvedValueOnce({
status: 'failed',
})
const warningModal = render(
<DSLConfirmModal
file={new File(['zip'], 'warning.zip', { type: 'application/zip' })}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(toast.warning).toHaveBeenCalledWith('app.newApp.caution', { description: 'app.newApp.appCreateDSLWarning' })
})
warningModal.unmount()
render(
<DSLConfirmModal
file={new File(['zip'], 'failed.zip', { type: 'application/zip' })}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('app.importBundleFailed')
})
})
it('should surface bundle import errors and invoke cancel actions', async () => {
const onCancel = vi.fn()
mockImportAppBundle.mockRejectedValue(new Error('boom'))
render(
<DSLConfirmModal
file={new File(['zip'], 'broken.zip', { type: 'application/zip' })}
onCancel={onCancel}
onConfirm={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Confirm' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('boom')
})
})
})

View File

@ -0,0 +1,141 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
const mockUseCreateFromDSLModal = vi.fn()
const mockDSLConfirmModal = vi.fn()
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({
children,
onOpenChange,
open,
}: {
children: ReactNode
onOpenChange?: (open: boolean) => void
open: boolean
}) => (open
? (
<div>
<button type="button" onClick={() => onOpenChange?.(false)}>close dialog</button>
{children}
</div>
)
: null),
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../use-create-from-dsl-modal', () => ({
useCreateFromDSLModal: (params: unknown) => mockUseCreateFromDSLModal(params),
}))
vi.mock('../dsl-confirm-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => {
mockDSLConfirmModal(onCancel)
return <button type="button" onClick={onCancel}>close confirm modal</button>
},
}))
const baseHookState = {
buttonDisabled: false,
currentFile: undefined,
currentTab: CreateFromDSLModalTab.FROM_FILE,
docHref: 'https://docs.example.com/app-management',
dslUrlValue: '',
handleConfirmSuccess: vi.fn(),
handleCreate: vi.fn(),
handleDSLConfirm: vi.fn(),
handleFile: vi.fn(),
isAppsFull: false,
isCreating: false,
isZipFile: vi.fn(() => false),
learnMoreLabel: 'Learn more',
setCurrentTab: vi.fn(),
setDslUrlValue: vi.fn(),
setShowErrorModal: vi.fn(),
showErrorModal: false,
tabs: [
{ key: CreateFromDSLModalTab.FROM_FILE, label: 'Import from file' },
{ key: CreateFromDSLModalTab.FROM_URL, label: 'Import from URL' },
],
versions: undefined,
}
describe('CreateFromDSLModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseCreateFromDSLModal.mockReturnValue(baseHookState)
})
it('should render the file tab content by default', () => {
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
expect(screen.getByText('Import from file')).toBeInTheDocument()
expect(screen.getByText('Learn more')).toHaveAttribute('href', 'https://docs.example.com/app-management')
})
it('should render the URL input when the hook reports the URL tab', () => {
mockUseCreateFromDSLModal.mockReturnValue({
...baseHookState,
currentTab: CreateFromDSLModalTab.FROM_URL,
dslUrlValue: 'https://example.com/app.yml',
})
render(<CreateFromDSLModal show={true} onClose={vi.fn()} activeTab={CreateFromDSLModalTab.FROM_URL} />)
expect(screen.getByDisplayValue('https://example.com/app.yml')).toBeInTheDocument()
})
it('should forward tab and input changes to the hook setters', () => {
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
fireEvent.click(screen.getByText('Import from URL'))
expect(baseHookState.setCurrentTab).toHaveBeenCalledWith(CreateFromDSLModalTab.FROM_URL)
mockUseCreateFromDSLModal.mockReturnValue({
...baseHookState,
currentTab: CreateFromDSLModalTab.FROM_URL,
})
render(<CreateFromDSLModal show={true} onClose={vi.fn()} activeTab={CreateFromDSLModalTab.FROM_URL} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'https://example.com/next.yml' } })
expect(baseHookState.setDslUrlValue).toHaveBeenCalledWith('https://example.com/next.yml')
})
it('should call the create handler when import is clicked', () => {
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.import/i }))
expect(baseHookState.handleCreate).toHaveBeenCalledTimes(1)
})
it('should close when the dialog requests close and when the header close button is clicked', () => {
const onClose = vi.fn()
const { container, rerender } = render(<CreateFromDSLModal show={true} onClose={onClose} />)
fireEvent.click(screen.getByText('close dialog'))
expect(onClose).toHaveBeenCalledTimes(1)
rerender(<CreateFromDSLModal show={true} onClose={onClose} />)
fireEvent.click(container.querySelector('.cursor-pointer')!)
expect(onClose).toHaveBeenCalledTimes(2)
})
it('should render the confirm modal and wire its cancel handler back to the hook state setter', () => {
mockUseCreateFromDSLModal.mockReturnValue({
...baseHookState,
showErrorModal: true,
currentFile: new File(['dsl'], 'demo.yml', { type: 'text/yaml' }),
versions: {
importedVersion: '0.9.0',
systemVersion: '1.0.0',
},
})
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />)
fireEvent.click(screen.getByText('close confirm modal'))
expect(baseHookState.setShowErrorModal).toHaveBeenCalledWith(false)
})
})

View File

@ -0,0 +1,98 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import Uploader from '../uploader'
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
describe('Uploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty state and trigger the file dialog', () => {
render(<Uploader file={undefined} updateFile={vi.fn()} />)
expect(screen.getByText('app.dslUploader.button')).toBeInTheDocument()
expect(screen.getByText('app.dslUploader.browse')).toBeInTheDocument()
})
it('should render the selected file and allow removal', () => {
const updateFile = vi.fn()
const file = new File(['yaml'], 'demo.yml', { type: 'text/yaml' })
render(<Uploader file={file} updateFile={updateFile} />)
expect(screen.getByText('demo.yml')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
expect(updateFile).toHaveBeenCalledTimes(1)
})
it('should reject dropping more than one file', async () => {
const updateFile = vi.fn()
const firstFile = new File(['one'], 'one.yml', { type: 'text/yaml' })
const secondFile = new File(['two'], 'two.yml', { type: 'text/yaml' })
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
const dropZone = container.querySelector('input + div')
expect(dropZone).not.toBeNull()
fireEvent.drop(dropZone as Element, {
dataTransfer: {
files: [firstFile, secondFile],
},
})
await waitFor(() => {
expect(toastErrorSpy).toHaveBeenCalledTimes(1)
})
expect(updateFile).not.toHaveBeenCalled()
})
it('should toggle drag state and accept a single dropped file', () => {
const updateFile = vi.fn()
const droppedFile = new File(['content'], 'single.yml', { type: 'text/yaml' })
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
const dropZone = container.querySelector('input + div') as Element
fireEvent.dragEnter(dropZone)
expect(dropZone.firstElementChild).toHaveClass('border-components-dropzone-border-accent')
const dragMask = dropZone.querySelector('.absolute') as Element
fireEvent.dragLeave(dragMask)
expect(dropZone.firstElementChild).not.toHaveClass('border-components-dropzone-border-accent')
fireEvent.drop(dropZone, {
dataTransfer: {
files: [droppedFile],
},
})
expect(updateFile).toHaveBeenCalledWith(droppedFile)
})
it('should restore the selection flow on cancel and update the file from the hidden input', () => {
const updateFile = vi.fn()
const nextFile = new File(['yaml'], 'next.yml', { type: 'text/yaml' })
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {})
fireEvent.click(screen.getByText('app.dslUploader.browse'))
expect(clickSpy).toHaveBeenCalledTimes(1)
fileInput.oncancel?.(new Event('cancel'))
expect(updateFile).toHaveBeenCalledWith(undefined)
fireEvent.change(fileInput, {
target: {
files: [nextFile],
},
})
expect(updateFile).toHaveBeenCalledWith(nextFile)
})
})

View File

@ -0,0 +1,477 @@
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { CreateFromDSLModalTab } from '../index'
import { useCreateFromDSLModal } from '../use-create-from-dsl-modal'
const mockPush = vi.fn()
const mockTrackEvent = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
const mockImportAppBundle = vi.fn()
const mockImportDSL = vi.fn()
const mockImportDSLConfirm = vi.fn()
const mockGetRedirection = vi.fn()
const mockUseKeyPress = vi.fn()
let mockProviderContext = {
enableBilling: true,
plan: {
usage: { buildApps: 0 },
total: { buildApps: 5 },
},
}
const capturedKeyCallbacks = new Map<string | string[], () => void>()
vi.mock('ahooks', () => ({
useKeyPress: (key: string | string[], callback: () => void) => {
capturedKeyCallbacks.set(Array.isArray(key) ? key.join('|') : key, callback)
mockUseKeyPress(key, callback)
},
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: true,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContext,
}))
vi.mock('@/service/apps', () => ({
importAppBundle: (...args: unknown[]) => mockImportAppBundle(...args),
importDSL: (...args: unknown[]) => mockImportDSL(...args),
importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: Object.assign(vi.fn(), {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
vi.mock('@/utils/app-redirection', () => ({
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
}))
class MockFileReader {
onload: ((event: { target?: { result?: string } }) => void) | null = null
readAsText(file: File) {
this.onload?.({ target: { result: `content:${file.name}` } })
}
}
const appManagementLocalizedPathMap: Record<string, DocPathWithoutLang> = {
'zh-Hans': '/use-dify/workspace/app-management',
}
describe('useCreateFromDSLModal', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedKeyCallbacks.clear()
vi.stubGlobal('FileReader', MockFileReader)
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
mockProviderContext = {
enableBilling: true,
plan: {
usage: { buildApps: 0 },
total: { buildApps: 5 },
},
}
})
it('should initialize with file tab defaults and a docs url', () => {
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_FILE,
dslUrl: '',
appManagementLocalizedPathMap,
}))
expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_FILE)
expect(result.current.buttonDisabled).toBe(true)
expect(result.current.docHref).toContain('/use-dify/workspace/app-management#app-export-and-import')
})
it('should read non-zip files and enable creation', async () => {
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_FILE,
dslUrl: '',
appManagementLocalizedPathMap,
}))
const file = new File(['yaml'], 'demo.yml', { type: 'text/yaml' })
await act(async () => {
result.current.handleFile(file)
})
expect(result.current.currentFile).toBe(file)
expect(result.current.buttonDisabled).toBe(false)
})
it('should initialize from a dropped file and clear cached content for zip uploads', async () => {
const droppedFile = new File(['yaml'], 'seed.yml', { type: 'text/yaml' })
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_FILE,
dslUrl: '',
droppedFile,
appManagementLocalizedPathMap,
}))
await waitFor(() => {
expect(result.current.currentFile).toBe(droppedFile)
})
const zipFile = new File(['zip'], 'bundle.zip', { type: 'application/zip' })
mockImportAppBundle.mockResolvedValue({
id: 'import-zip',
status: 'completed',
app_id: 'app-zip',
app_mode: 'chat',
})
await act(async () => {
result.current.handleFile(zipFile)
})
await waitFor(() => {
expect(result.current.currentFile).toBe(zipFile)
})
await act(async () => {
await result.current.handleCreate()
})
expect(mockImportAppBundle).toHaveBeenCalledWith({ file: zipFile })
})
it('should import from URL and complete the success flow', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-1',
status: 'completed',
app_id: 'app-1',
app_mode: 'chat',
})
const onSuccess = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onSuccess,
onClose,
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
await result.current.handleCreate()
})
expect(mockImportDSL).toHaveBeenCalledWith({
mode: 'yaml-url',
yaml_url: 'https://example.com/app.yml',
})
expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({
creation_method: 'dsl_url',
has_warnings: false,
}))
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
expect(mockGetRedirection).toHaveBeenCalledWith(true, { id: 'app-1', mode: 'chat' }, mockPush)
})
it('should open the confirm modal when the DSL import is pending', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-2',
status: 'pending',
imported_dsl_version: '0.9.0',
current_dsl_version: '1.0.0',
})
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
await result.current.handleCreate()
})
await waitFor(() => {
expect(result.current.showErrorModal).toBe(true)
})
expect(result.current.versions).toEqual({
importedVersion: '0.9.0',
systemVersion: '1.0.0',
})
})
it('should return early when required input is missing or the import returns no response', async () => {
const missingFile = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_FILE,
dslUrl: '',
appManagementLocalizedPathMap,
}))
await act(async () => {
await missingFile.result.current.handleCreate()
})
expect(mockImportDSL).not.toHaveBeenCalled()
expect(mockImportAppBundle).not.toHaveBeenCalled()
const missingUrl = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: '',
appManagementLocalizedPathMap,
}))
await act(async () => {
await missingUrl.result.current.handleCreate()
})
expect(mockImportDSL).not.toHaveBeenCalled()
mockImportDSL.mockResolvedValue(undefined)
const noResponse = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
await noResponse.result.current.handleCreate()
})
expect(mockImportDSL).toHaveBeenCalledWith({
mode: 'yaml-url',
yaml_url: 'https://example.com/app.yml',
})
expect(toast.error).not.toHaveBeenCalled()
})
it('should confirm a pending import through importDSLConfirm', async () => {
mockImportDSLConfirm.mockResolvedValue({
status: 'completed',
app_id: 'app-2',
app_mode: 'workflow',
})
const onSuccess = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onSuccess,
onClose,
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
result.current.setShowErrorModal(true)
})
await act(async () => {
// Seed the pending import id by taking the pending path first.
mockImportDSL.mockResolvedValueOnce({
id: 'import-3',
status: 'pending',
imported_dsl_version: '0.9.0',
current_dsl_version: '1.0.0',
})
await result.current.handleCreate()
})
await act(async () => {
await result.current.handleDSLConfirm()
})
expect(mockImportDSLConfirm).toHaveBeenCalledWith({
import_id: 'import-3',
})
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('should guard duplicate submissions and surface failed confirmation flows', async () => {
let resolveImport: ((value: { id: string, status: string }) => void) | undefined
mockImportDSL.mockReturnValue(new Promise((resolve) => {
resolveImport = resolve
}))
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
const firstCreate = act(async () => {
await result.current.handleCreate()
})
await act(async () => {
await result.current.handleCreate()
})
expect(mockImportDSL).toHaveBeenCalledTimes(1)
resolveImport?.({
id: 'import-guard',
status: 'failed',
})
await firstCreate
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
expect(result.current.isCreating).toBe(false)
mockImportDSL.mockResolvedValueOnce({
id: 'import-confirm',
status: 'pending',
imported_dsl_version: '0.9.0',
current_dsl_version: '1.0.0',
})
await act(async () => {
await result.current.handleCreate()
})
mockImportDSLConfirm.mockResolvedValueOnce({
status: 'failed',
}).mockRejectedValueOnce(new Error('broken'))
await act(async () => {
await result.current.handleDSLConfirm()
})
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
await act(async () => {
await result.current.handleDSLConfirm()
})
expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed')
})
it('should trigger create on the keyboard shortcut and close on escape', async () => {
mockImportDSL.mockResolvedValue({
id: 'import-4',
status: 'failed',
})
const onClose = vi.fn()
renderHook(() => useCreateFromDSLModal({
show: true,
onClose,
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
capturedKeyCallbacks.get('meta.enter|ctrl.enter')?.()
})
expect(mockImportDSL).toHaveBeenCalledTimes(1)
act(() => {
capturedKeyCallbacks.get('esc')?.()
})
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should skip keyboard creation when hidden or when the workspace is out of app quota', async () => {
mockProviderContext = {
enableBilling: true,
plan: {
usage: { buildApps: 5 },
total: { buildApps: 5 },
},
}
renderHook(() => useCreateFromDSLModal({
show: false,
onClose: vi.fn(),
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
capturedKeyCallbacks.get('meta.enter|ctrl.enter')?.()
})
expect(mockImportDSL).not.toHaveBeenCalled()
})
it('should expose manual confirm success and ignore confirm requests without an import id', async () => {
const onSuccess = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useCreateFromDSLModal({
show: true,
onSuccess,
onClose,
activeTab: CreateFromDSLModalTab.FROM_URL,
dslUrl: 'https://example.com/app.yml',
appManagementLocalizedPathMap,
}))
await act(async () => {
await result.current.handleDSLConfirm()
})
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
act(() => {
result.current.handleConfirmSuccess()
})
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,41 +1,22 @@
'use client'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useKeyPress } from 'ahooks'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { useRouter } from '@/next/navigation'
import {
importAppBundle,
importDSL,
importDSLConfirm,
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import ShortcutsName from '../../workflow/shortcuts-name'
import DSLConfirmModal from './dsl-confirm-modal'
import Uploader from './uploader'
import { useCreateFromDSLModal } from './use-create-from-dsl-modal'
type CreateFromDSLModalProps = {
show: boolean
onSuccess?: () => void
onClose: () => void
activeTab?: string
activeTab?: CreateFromDSLModalTab
dslUrl?: string
droppedFile?: File
}
@ -50,207 +31,38 @@ const appManagementLocalizedPathMap = {
'zh_Hans': '/use-dify/workspace/app-management#应用导出和导入' as DocPathWithoutLang,
'ja-JP': '/use-dify/workspace/app-management#アプリのエクスポートとインポート' as DocPathWithoutLang,
'ja_JP': '/use-dify/workspace/app-management#アプリのエクスポートとインポート' as DocPathWithoutLang,
}
} satisfies Record<string, DocPathWithoutLang>
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
const { push } = useRouter()
const { t } = useTranslation()
const docLink = useDocLink()
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const [isCreating, setIsCreating] = useState(false)
const { handleCheckPluginDependencies } = usePluginDependencies()
const isZipFile = (file?: File) => !!file && file.name.toLowerCase().endsWith('.zip')
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setDSLFile(file)
if (file && !isZipFile(file))
readFile(file)
if (!file || isZipFile(file))
setFileContent('')
}
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
const isCreatingRef = useRef(false)
const isMountedRef = useRef(true)
useEffect(() => {
if (droppedFile)
handleFile(droppedFile)
}, [droppedFile])
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
return
if (isCreatingRef.current)
return
isCreatingRef.current = true
setIsCreating(true)
try {
let response
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
if (isZipFile(currentFile)) {
response = await importAppBundle({ file: currentFile! })
}
else {
response = await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: fileContent || '',
})
}
}
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
response = await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
}
if (!response)
return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
// Track app creation from DSL import
trackEvent('create_app_with_dsl', {
app_mode,
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
})
if (onSuccess)
onSuccess()
if (onClose)
onClose()
toast(t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }) })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (app_id)
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setImportId(id)
}
else {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
finally {
isCreatingRef.current = false
if (isMountedRef.current)
setIsCreating(false)
}
}
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && dslUrlValue)))
onCreate(undefined)
})
useKeyPress('esc', () => {
if (show && !showErrorModal)
onClose()
})
const onDSLConfirm = async () => {
try {
if (!importId)
return
const response = await importDSLConfirm({
import_id: importId,
})
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED) {
if (onSuccess)
onSuccess()
if (onClose)
onClose()
toast.success(t('newApp.appCreated', { ns: 'app' }))
if (app_id)
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
const handleConfirmSuccess = () => {
if (onSuccess)
onSuccess()
onClose()
}
const tabs = [
{
key: CreateFromDSLModalTab.FROM_FILE,
label: t('importFromDSLFile', { ns: 'app' }),
},
{
key: CreateFromDSLModalTab.FROM_URL,
label: t('importFromDSLUrl', { ns: 'app' }),
},
]
const buttonDisabled = useMemo(() => {
if (isAppsFull)
return true
if (currentTab === CreateFromDSLModalTab.FROM_FILE)
return !currentFile
if (currentTab === CreateFromDSLModalTab.FROM_URL)
return !dslUrlValue
return false
}, [isAppsFull, currentTab, currentFile, dslUrlValue])
const learnMoreLabel = t('importFromDSLModal.learnMore', {
ns: 'app',
defaultValue: t('newApp.learnMore', { ns: 'app' }),
const {
buttonDisabled,
currentFile,
currentTab,
docHref,
dslUrlValue,
handleConfirmSuccess,
handleCreate,
handleDSLConfirm,
handleFile,
isAppsFull,
isCreating,
isZipFile,
learnMoreLabel,
setCurrentTab,
setDslUrlValue,
setShowErrorModal,
showErrorModal,
tabs,
versions,
} = useCreateFromDSLModal({
show,
onSuccess,
onClose,
activeTab,
dslUrl,
droppedFile,
appManagementLocalizedPathMap,
})
return (
@ -322,7 +134,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<div className="flex items-center justify-between px-6 pb-6 pt-5">
<a
className="flex items-center gap-1 text-text-accent system-xs-regular"
href={docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap)}
href={docHref}
target="_blank"
rel="noopener noreferrer"
>
@ -336,7 +148,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<Button
disabled={buttonDisabled || isCreating}
variant="primary"
onClick={onCreate}
onClick={handleCreate}
className="gap-1"
loading={isCreating}
>
@ -352,7 +164,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
file={currentFile}
versions={versions}
onCancel={() => setShowErrorModal(false)}
onConfirm={onDSLConfirm}
onConfirm={handleDSLConfirm}
onSuccess={handleConfirmSuccess}
/>
)}

View File

@ -0,0 +1,269 @@
'use client'
import type { CreateFromDSLModalTab } from './index'
import type { AppModeEnum } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useKeyPress } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { toast } from '@/app/components/base/ui/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import { useRouter } from '@/next/navigation'
import {
importAppBundle,
importDSL,
importDSLConfirm,
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
type CreateFromDSLModalStateParams = {
show: boolean
onSuccess?: () => void
onClose: () => void
activeTab: CreateFromDSLModalTab
dslUrl: string
droppedFile?: File
appManagementLocalizedPathMap: Record<string, DocPathWithoutLang>
}
export const useCreateFromDSLModal = ({
show,
onSuccess,
onClose,
activeTab,
dslUrl,
droppedFile,
appManagementLocalizedPathMap,
}: CreateFromDSLModalStateParams) => {
const { push } = useRouter()
const { t } = useTranslation()
const docLink = useDocLink()
const { handleCheckPluginDependencies } = usePluginDependencies()
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
const [currentFile, setCurrentFile] = useState<File | undefined>(() => droppedFile)
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const [isCreating, setIsCreating] = useState(false)
const isCreatingRef = useRef(false)
const isMountedRef = useRef(true)
const isZipFile = useCallback((file?: File) => !!file && file.name.toLowerCase().endsWith('.zip'), [])
const readFileContent = useCallback((file: File) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
resolve(String(event.target?.result || ''))
}
reader.onerror = () => {
reject(new Error(`Unable to read ${file.name}`))
}
reader.readAsText(file)
})
}, [])
const handleFile = useCallback((file?: File) => {
setCurrentFile(file)
}, [])
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
const handleImportSuccess = useCallback(async (status: DSLImportStatus, appId?: string, appMode?: AppModeEnum) => {
if (onSuccess)
onSuccess()
onClose()
toast(
t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
{
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? t('newApp.appCreateDSLWarning', { ns: 'app' })
: undefined,
},
)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (appId)
await handleCheckPluginDependencies(appId)
if (appId && appMode)
getRedirection(isCurrentWorkspaceEditor, { id: appId, mode: appMode }, push)
}, [handleCheckPluginDependencies, isCurrentWorkspaceEditor, onClose, onSuccess, push, t])
const handlePendingImport = useCallback((id?: string, importedVersion?: string | null, currentVersion?: string | null) => {
setVersions({
importedVersion: importedVersion ?? '',
systemVersion: currentVersion ?? '',
})
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setImportId(id)
}, [])
const handleCreate = useCallback(async () => {
if (currentTab === 'from-file' && !currentFile)
return
if (currentTab === 'from-url' && !dslUrlValue)
return
if (isCreatingRef.current)
return
isCreatingRef.current = true
setIsCreating(true)
try {
const response = currentTab === 'from-file'
? (isZipFile(currentFile)
? await importAppBundle({ file: currentFile! })
: await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: await readFileContent(currentFile!),
}))
: await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
if (!response)
return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
trackEvent('create_app_with_dsl', {
app_mode,
creation_method: currentTab === 'from-file' ? 'dsl_file' : 'dsl_url',
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
})
await handleImportSuccess(status, app_id, app_mode as AppModeEnum | undefined)
return
}
if (status === DSLImportStatus.PENDING) {
handlePendingImport(id, imported_dsl_version, current_dsl_version)
return
}
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
finally {
isCreatingRef.current = false
if (isMountedRef.current)
setIsCreating(false)
}
}, [currentFile, currentTab, dslUrlValue, handleImportSuccess, handlePendingImport, isZipFile, readFileContent, t])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!show || isAppsFull)
return
const canCreate = (currentTab === 'from-file' && currentFile) || (currentTab === 'from-url' && dslUrlValue)
if (canCreate)
handleCreate()
})
useKeyPress('esc', () => {
if (show && !showErrorModal)
onClose()
})
const handleDSLConfirm = useCallback(async () => {
try {
if (!importId)
return
const response = await importDSLConfirm({
import_id: importId,
})
const { status, app_id, app_mode } = response
if (status === DSLImportStatus.COMPLETED) {
await handleImportSuccess(status, app_id, app_mode as AppModeEnum | undefined)
return
}
if (status === DSLImportStatus.FAILED)
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}, [handleImportSuccess, importId, t])
const handleConfirmSuccess = useCallback(() => {
if (onSuccess)
onSuccess()
onClose()
}, [onClose, onSuccess])
const tabs = useMemo<Array<{ key: CreateFromDSLModalTab, label: string }>>(() => ([
{
key: 'from-file' as CreateFromDSLModalTab,
label: t('importFromDSLFile', { ns: 'app' }),
},
{
key: 'from-url' as CreateFromDSLModalTab,
label: t('importFromDSLUrl', { ns: 'app' }),
},
]), [t])
const buttonDisabled = useMemo(() => {
if (isAppsFull)
return true
if (currentTab === 'from-file')
return !currentFile
if (currentTab === 'from-url')
return !dslUrlValue
return false
}, [currentFile, currentTab, dslUrlValue, isAppsFull])
const learnMoreLabel = t('importFromDSLModal.learnMore', {
ns: 'app',
defaultValue: t('newApp.learnMore', { ns: 'app' }),
})
return {
buttonDisabled,
currentFile,
currentTab,
docHref: docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap),
dslUrlValue,
handleConfirmSuccess,
handleCreate,
handleDSLConfirm,
handleFile,
isAppsFull,
isCreating,
isZipFile,
learnMoreLabel,
setCurrentTab,
setDslUrlValue,
setShowErrorModal,
showErrorModal,
tabs,
versions,
}
}

View File

@ -0,0 +1,186 @@
import type { SandboxProvider } from '@/types/sandbox-provider'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { toast } from '@/app/components/base/ui/toast'
import {
useActivateSandboxProvider,
useDeleteSandboxProviderConfig,
useSaveSandboxProviderConfig,
} from '@/service/use-sandbox-provider'
import ConfigModal from '../config-modal'
const mockUseSaveSandboxProviderConfig = vi.mocked(useSaveSandboxProviderConfig)
const mockUseDeleteSandboxProviderConfig = vi.mocked(useDeleteSandboxProviderConfig)
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
let mockFormValues = {
isCheckValidated: true,
values: {
api_key: 'secret-key',
},
}
vi.mock('@/service/use-sandbox-provider', () => ({
useSaveSandboxProviderConfig: vi.fn(),
useDeleteSandboxProviderConfig: vi.fn(),
useActivateSandboxProvider: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
vi.mock('@/app/components/base/form/components/base', () => ({
BaseForm: React.forwardRef((_props: object, ref: React.ForwardedRef<{ getFormValues: () => typeof mockFormValues }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormValues,
}))
return <div data-testid="base-form" />
}),
}))
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
provider_type: 'e2b',
is_system_configured: true,
is_tenant_configured: false,
is_active: false,
config: {},
config_schema: [
{ name: 'api_key', type: 'secret' },
],
...overrides,
})
describe('Sandbox ConfigModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormValues = {
isCheckValidated: true,
values: {
api_key: 'secret-key',
},
}
mockUseSaveSandboxProviderConfig.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
mockUseDeleteSandboxProviderConfig.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
})
// Covers the managed-mode activation shortcut for providers with system config.
describe('managed mode', () => {
it('should activate the managed provider when saving in managed mode', async () => {
const activateProvider = vi.fn().mockResolvedValue(undefined)
const onClose = vi.fn()
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync: activateProvider,
isPending: false,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
render(<ConfigModal provider={createProvider()} onClose={onClose} />)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
await waitFor(() => expect(activateProvider).toHaveBeenCalledWith({
providerType: 'e2b',
type: 'system',
}))
expect(toast.success).toHaveBeenCalledWith('common.api.saved')
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Covers BYOK save and revoke flows.
describe('byok mode', () => {
it('should switch between managed and BYOK modes for providers that support both', () => {
render(<ConfigModal provider={createProvider()} onClose={vi.fn()} />)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.sandboxProvider.configModal.bringYourOwnKey'))
expect(screen.getByTestId('base-form')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.sandboxProvider.configModal.managedByDify'))
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
it('should save BYOK config when the form validates', async () => {
const saveConfig = vi.fn().mockResolvedValue(undefined)
const onClose = vi.fn()
mockUseSaveSandboxProviderConfig.mockReturnValue({
mutateAsync: saveConfig,
isPending: false,
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
render(<ConfigModal provider={createProvider({ is_system_configured: false })} onClose={onClose} />)
expect(screen.getByTestId('base-form')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
await waitFor(() => expect(saveConfig).toHaveBeenCalledWith({
providerType: 'e2b',
config: { api_key: 'secret-key' },
activate: true,
}))
expect(toast.success).toHaveBeenCalledWith('common.api.saved')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not save when the form validation fails', () => {
const saveConfig = vi.fn().mockResolvedValue(undefined)
mockFormValues = {
isCheckValidated: false,
values: {
api_key: '',
},
}
mockUseSaveSandboxProviderConfig.mockReturnValue({
mutateAsync: saveConfig,
isPending: false,
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
render(<ConfigModal provider={createProvider({ is_system_configured: false })} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.save' }))
expect(saveConfig).not.toHaveBeenCalled()
})
it('should revoke tenant config when revoke is available', async () => {
const deleteConfig = vi.fn().mockResolvedValue(undefined)
const onClose = vi.fn()
mockUseDeleteSandboxProviderConfig.mockReturnValue({
mutateAsync: deleteConfig,
isPending: false,
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
render(
<ConfigModal
provider={createProvider({
is_system_configured: false,
is_tenant_configured: true,
})}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.revoke' }))
await waitFor(() => expect(deleteConfig).toHaveBeenCalledWith('e2b'))
expect(toast.success).toHaveBeenCalledWith('common.api.remove')
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,164 @@
import type { SandboxProvider } from '@/types/sandbox-provider'
import { useQuery } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import {
useActivateSandboxProvider,
useDeleteSandboxProviderConfig,
useSaveSandboxProviderConfig,
} from '@/service/use-sandbox-provider'
import SandboxProviderPage from '../index'
const mockUseQuery = vi.mocked(useQuery)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseSaveSandboxProviderConfig = vi.mocked(useSaveSandboxProviderConfig)
const mockUseDeleteSandboxProviderConfig = vi.mocked(useDeleteSandboxProviderConfig)
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
return {
...actual,
useQuery: vi.fn(),
}
})
vi.mock('@/context/app-context', async () => {
const actual = await vi.importActual<typeof import('@/context/app-context')>('@/context/app-context')
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('@/service/use-sandbox-provider', () => ({
useSaveSandboxProviderConfig: vi.fn(),
useDeleteSandboxProviderConfig: vi.fn(),
useActivateSandboxProvider: vi.fn(),
}))
vi.mock('@/app/components/base/form/components/base', () => ({
BaseForm: () => <div data-testid="base-form" />,
}))
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
provider_type: 'e2b',
is_system_configured: false,
is_tenant_configured: true,
is_active: false,
config: {},
config_schema: [{ name: 'api_key', type: 'secret' }],
...overrides,
})
describe('SandboxProviderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: true,
isLoadingCurrentWorkspace: false,
} as ReturnType<typeof useAppContext>)
mockUseQuery.mockReturnValue({
data: [],
isLoading: false,
} as unknown as ReturnType<typeof useQuery>)
mockUseSaveSandboxProviderConfig.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useSaveSandboxProviderConfig>)
mockUseDeleteSandboxProviderConfig.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useDeleteSandboxProviderConfig>)
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
})
// Covers loading fallback while sandbox providers are being fetched.
describe('loading', () => {
it('should render the loading state while the provider list is pending', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
} as unknown as ReturnType<typeof useQuery>)
const { container } = render(<SandboxProviderPage />)
expect(container.querySelector('[role="status"]')).toBeInTheDocument()
})
})
// Covers section rendering based on provider state and user permission.
describe('sections', () => {
it('should render current and other providers along with the no-permission hint', () => {
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: false,
isLoadingCurrentWorkspace: false,
} as ReturnType<typeof useAppContext>)
mockUseQuery.mockReturnValue({
data: [
createProvider({ provider_type: 'e2b', is_active: true }),
createProvider({ provider_type: 'docker' }),
],
isLoading: false,
} as unknown as ReturnType<typeof useQuery>)
render(<SandboxProviderPage />)
expect(screen.getByText('common.sandboxProvider.currentProvider')).toBeInTheDocument()
expect(screen.getByText('common.sandboxProvider.otherProvider')).toBeInTheDocument()
expect(screen.getByText('E2B')).toBeInTheDocument()
expect(screen.getByText('Docker')).toBeInTheDocument()
expect(screen.getByText('common.sandboxProvider.noPermission')).toBeInTheDocument()
})
})
// Covers opening the config and switch modals from card actions.
describe('modal triggers', () => {
it('should open config and switch modals from provider card actions', () => {
mockUseQuery.mockReturnValue({
data: [
createProvider({ provider_type: 'e2b', is_active: true }),
createProvider({ provider_type: 'docker' }),
],
isLoading: false,
} as unknown as ReturnType<typeof useQuery>)
const { container } = render(<SandboxProviderPage />)
const buttons = container.querySelectorAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(3)
fireEvent.click(buttons[0])
expect(screen.getByText('common.sandboxProvider.configModal.title')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.configModal.cancel' }))
expect(screen.queryByText('common.sandboxProvider.configModal.title')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.sandboxProvider.setAsActive'))
expect(screen.getByText('common.sandboxProvider.switchModal.title')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' }))
expect(screen.queryByText('common.sandboxProvider.switchModal.title')).not.toBeInTheDocument()
})
it('should open the config modal from an inactive provider card', () => {
mockUseQuery.mockReturnValue({
data: [
createProvider({ provider_type: 'e2b', is_active: true }),
createProvider({ provider_type: 'docker' }),
],
isLoading: false,
} as unknown as ReturnType<typeof useQuery>)
const { container } = render(<SandboxProviderPage />)
const buttons = container.querySelectorAll('button')
fireEvent.click(buttons[2])
expect(screen.getAllByText('Docker').length).toBeGreaterThan(0)
expect(screen.getByText('common.sandboxProvider.configModal.title')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,82 @@
import type { SandboxProvider } from '@/types/sandbox-provider'
import { fireEvent, render, screen } from '@testing-library/react'
import ProviderCard from '../provider-card'
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
provider_type: 'e2b',
is_system_configured: false,
is_tenant_configured: true,
is_active: false,
config: {},
config_schema: [],
...overrides,
})
describe('Sandbox ProviderCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers the current-provider presentation path.
describe('current provider', () => {
it('should render the connected state and config action for the active provider', () => {
const onConfig = vi.fn()
const { container } = render(
<ProviderCard
provider={createProvider({ is_active: true })}
isCurrent
onConfig={onConfig}
/>,
)
expect(screen.getByText('E2B')).toBeInTheDocument()
expect(screen.getByText('common.sandboxProvider.connected')).toBeInTheDocument()
expect(screen.queryByText('common.sandboxProvider.setAsActive')).not.toBeInTheDocument()
const buttons = container.querySelectorAll('button')
expect(buttons).toHaveLength(1)
fireEvent.click(buttons[0])
expect(onConfig).toHaveBeenCalledTimes(1)
})
})
// Covers action availability for other configured providers.
describe('other providers', () => {
it('should render enable and config actions when the provider is configured', () => {
const onConfig = vi.fn()
const onEnable = vi.fn()
const { container } = render(
<ProviderCard
provider={createProvider()}
onConfig={onConfig}
onEnable={onEnable}
/>,
)
expect(screen.getByText('common.sandboxProvider.setAsActive')).toBeInTheDocument()
const buttons = container.querySelectorAll('button')
expect(buttons).toHaveLength(2)
fireEvent.click(buttons[0])
fireEvent.click(buttons[1])
expect(onEnable).toHaveBeenCalledTimes(1)
expect(onConfig).toHaveBeenCalledTimes(1)
})
it('should hide actions when the card is disabled', () => {
const { container } = render(
<ProviderCard
provider={createProvider()}
onConfig={vi.fn()}
onEnable={vi.fn()}
disabled
/>,
)
expect(screen.queryByText('common.sandboxProvider.setAsActive')).not.toBeInTheDocument()
expect(container.querySelectorAll('button')).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,54 @@
import { render } from '@testing-library/react'
import ProviderIcon from '../provider-icon'
describe('Sandbox ProviderIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers built-in branded render paths.
describe('built-in providers', () => {
it('should render docker with border wrapper and svg icon', () => {
const { container } = render(<ProviderIcon providerType="docker" withBorder />)
expect(container.querySelector('svg')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('rounded')
})
it('should render ssh with the dedicated image asset', () => {
const { getByAltText } = render(<ProviderIcon providerType="ssh" />)
expect(getByAltText('ssh icon')).toHaveAttribute('src', '/sandbox-providers/ssh.svg')
})
it('should render local and ssh providers with bordered wrappers', () => {
const local = render(<ProviderIcon providerType="local" withBorder size="sm" />)
expect(local.container.querySelector('svg')).toBeInTheDocument()
expect(local.container.firstElementChild).toHaveClass('rounded')
local.unmount()
const ssh = render(<ProviderIcon providerType="ssh" withBorder />)
expect(ssh.getByAltText('ssh icon')).toHaveAttribute('src', '/sandbox-providers/ssh.svg')
expect(ssh.container.firstElementChild).toHaveClass('rounded')
})
})
// Covers the fallback icon path for unknown providers.
describe('fallback providers', () => {
it('should fall back to the configured icon asset for unknown providers', () => {
const { getByAltText } = render(<ProviderIcon providerType="unknown-provider" size="sm" />)
expect(getByAltText('unknown-provider icon')).toHaveAttribute('src', '/sandbox-providers/e2b.svg')
})
it('should wrap fallback icons in a border when requested', () => {
const { container, getByAltText } = render(<ProviderIcon providerType="custom-provider" withBorder />)
expect(getByAltText('custom-provider icon')).toHaveAttribute('src', '/sandbox-providers/e2b.svg')
expect(container.firstElementChild).toHaveClass('rounded')
})
})
})

View File

@ -0,0 +1,86 @@
import type { SandboxProvider } from '@/types/sandbox-provider'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import { useActivateSandboxProvider } from '@/service/use-sandbox-provider'
import SwitchModal from '../switch-modal'
const mockUseActivateSandboxProvider = vi.mocked(useActivateSandboxProvider)
vi.mock('@/service/use-sandbox-provider', () => ({
useActivateSandboxProvider: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
const createProvider = (overrides: Partial<SandboxProvider> = {}): SandboxProvider => ({
provider_type: 'e2b',
is_system_configured: false,
is_tenant_configured: true,
is_active: false,
config: {},
config_schema: [],
...overrides,
})
describe('Sandbox SwitchModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue(undefined),
isPending: false,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
})
// Covers close interactions without activation.
describe('closing', () => {
it('should close when clicking the cancel button', () => {
const onClose = vi.fn()
render(<SwitchModal provider={createProvider()} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Covers provider activation flows.
describe('activation', () => {
it('should activate the provider, show success toast, and close the modal', async () => {
const mutateAsync = vi.fn().mockResolvedValue(undefined)
const onClose = vi.fn()
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
render(<SwitchModal provider={createProvider()} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.confirm' }))
await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith({
providerType: 'e2b',
type: 'user',
}))
expect(toast.success).toHaveBeenCalledWith('common.api.success')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should disable actions while activation is pending', () => {
mockUseActivateSandboxProvider.mockReturnValue({
mutateAsync: vi.fn(),
isPending: true,
} as unknown as ReturnType<typeof useActivateSandboxProvider>)
render(<SwitchModal provider={createProvider()} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.cancel' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'common.sandboxProvider.switchModal.confirm' })).toBeDisabled()
})
})
})

View File

@ -0,0 +1,153 @@
import type { GitHubItemAndMarketPlaceDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
import { render, screen, waitFor } from '@testing-library/react'
import { useUploadGitHub } from '@/service/use-plugins'
import GitHubItem from '../github-item'
const mockUseUploadGitHub = vi.mocked(useUploadGitHub)
const mockPluginManifestToCardPluginProps = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useUploadGitHub: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
}))
vi.mock('../loaded-item', () => ({
default: ({ payload, checked }: { payload: Plugin, checked: boolean }) => (
<div data-testid="loaded-item" data-plugin-id={payload.plugin_id} data-from={payload.from} data-checked={String(checked)}>
{payload.name}
</div>
),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
name: 'Plugin Name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'github',
...overrides,
})
const versionInfo: VersionProps = {
hasInstalled: false,
toInstallVersion: '0.0.1',
}
const dependency: GitHubItemAndMarketPlaceDependency = {
type: 'github',
value: {
repo: 'org/repo',
release: '1.2.3',
packages: 'tool',
},
}
describe('GitHubItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseUploadGitHub.mockReturnValue({
data: undefined,
error: undefined,
} as unknown as ReturnType<typeof useUploadGitHub>)
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin())
})
// Covers request parameter normalization and loading state.
describe('loading', () => {
it('should request the github package using release and packages fallbacks', () => {
render(
<GitHubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={vi.fn()}
/>,
)
expect(mockUseUploadGitHub).toHaveBeenCalledWith({
repo: 'org/repo',
version: '1.2.3',
package: 'tool',
})
expect(screen.getByText('loading')).toBeInTheDocument()
})
})
// Covers success and failure side effects.
describe('effects', () => {
it('should transform the fetched manifest and render the loaded item', async () => {
const onFetchedPayload = vi.fn()
mockUseUploadGitHub.mockReturnValue({
data: {
manifest: { plugin_unique_identifier: 'manifest-plugin-id' },
unique_identifier: 'github-uid',
},
error: undefined,
} as unknown as ReturnType<typeof useUploadGitHub>)
render(
<GitHubItem
checked
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={onFetchedPayload}
onFetchError={vi.fn()}
/>,
)
await waitFor(() => expect(onFetchedPayload).toHaveBeenCalledWith(expect.objectContaining({
plugin_id: 'github-uid',
})))
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-plugin-id', 'github-uid')
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-from', 'github')
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-checked', 'true')
})
it('should notify the parent when the github upload hook returns an error', async () => {
const onFetchError = vi.fn()
mockUseUploadGitHub.mockReturnValue({
data: undefined,
error: new Error('failed'),
} as unknown as ReturnType<typeof useUploadGitHub>)
render(
<GitHubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={onFetchError}
/>,
)
await waitFor(() => expect(onFetchError).toHaveBeenCalledTimes(1))
})
})
})

View File

@ -0,0 +1,336 @@
import type { PackageDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { useUploadGitHub } from '@/service/use-plugins'
import GitHubItem from '../github-item'
import LoadedItem from '../loaded-item'
import MarketPlaceItem from '../marketplace-item'
import PackageItem from '../package-item'
const mockUseUploadGitHub = vi.mocked(useUploadGitHub)
let mockCanInstall = true
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
const mockPluginManifestToCardPluginProps = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useUploadGitHub: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: mockGetIconUrl,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
default: () => ({
canInstall: mockCanInstall,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, titleLeft, limitedInstall }: { payload: Plugin, titleLeft?: React.ReactNode, limitedInstall?: boolean }) => (
<div
data-testid="plugin-card"
data-icon={payload.icon}
data-plugin-id={payload.plugin_id}
data-limited-install={String(!!limitedInstall)}
>
{titleLeft}
<span>{payload.name}</span>
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/base/version', () => ({
default: ({ toInstallVersion }: VersionProps) => <span data-testid="version-badge">{toInstallVersion}</span>,
}))
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
default: () => <div>loading</div>,
}))
vi.mock('@/app/components/plugins/install-plugin/base/loading-error', () => ({
default: () => <div>loading-error</div>,
}))
const versionInfo: VersionProps = {
hasInstalled: false,
toInstallVersion: '0.0.1',
}
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
plugin_id: 'plugin-id',
type: 'tool',
category: 'tool' as Plugin['category'],
name: 'plugin-name',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'package-id',
org: 'plugin-org',
label: { en_US: 'Plugin Name' } as Plugin['label'],
brief: { en_US: 'Plugin brief' } as Plugin['brief'],
description: { en_US: 'Plugin description' } as Plugin['description'],
icon: 'icon.png',
verified: false,
introduction: '',
repository: '',
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'github',
...overrides,
})
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
type: 'package',
value: {
unique_identifier: 'package-uid',
manifest: { plugin_unique_identifier: 'manifest-plugin-id' } as PackageDependency['value']['manifest'],
},
...overrides,
})
describe('install bundle item components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanInstall = true
mockUseUploadGitHub.mockReturnValue({
data: undefined,
error: undefined,
} as unknown as ReturnType<typeof useUploadGitHub>)
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin({
plugin_id: 'manifest-plugin-id',
from: 'package',
}))
})
// Covers the base loaded card behavior for local and marketplace items.
describe('LoadedItem', () => {
it('should use local icon urls and emit the payload when selected', () => {
const payload = createPlugin()
const onCheckedChange = vi.fn()
render(
<LoadedItem
checked={false}
onCheckedChange={onCheckedChange}
payload={payload}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
expect(screen.getByTestId('version-badge')).toHaveTextContent('1.0.0')
fireEvent.click(screen.getByRole('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(payload)
})
it('should use marketplace icon urls and disable selection when install is restricted', () => {
mockCanInstall = false
const payload = createPlugin({
org: 'market-org',
name: 'market-plugin',
})
render(
<LoadedItem
checked
isFromMarketPlace
onCheckedChange={vi.fn()}
payload={payload}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute(
'data-icon',
`${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-limited-install', 'true')
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true')
})
})
// Covers the marketplace wrapper loading state and version override behavior.
describe('MarketPlaceItem', () => {
it('should render loading while the marketplace payload is unavailable', () => {
render(
<MarketPlaceItem
checked={false}
onCheckedChange={vi.fn()}
payload={undefined}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should pass the marketplace version into the loaded item payload', () => {
const onCheckedChange = vi.fn()
render(
<MarketPlaceItem
checked={false}
onCheckedChange={onCheckedChange}
payload={createPlugin({ version: '1.0.0' })}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('version-badge')).toHaveTextContent('2.0.0')
fireEvent.click(screen.getByRole('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
version: '2.0.0',
}))
})
})
// Covers local package conversion and missing-manifest fallback.
describe('PackageItem', () => {
it('should render a loading error when the package manifest is missing', () => {
render(
<PackageItem
checked={false}
onCheckedChange={vi.fn()}
payload={createPackageDependency({
value: {
unique_identifier: 'missing-manifest',
manifest: undefined as unknown as PackageDependency['value']['manifest'],
},
})}
versionInfo={versionInfo}
/>,
)
expect(screen.getByText('loading-error')).toBeInTheDocument()
})
it('should convert package manifests and forward the packaged plugin payload', () => {
const onCheckedChange = vi.fn()
render(
<PackageItem
checked={false}
onCheckedChange={onCheckedChange}
payload={createPackageDependency()}
versionInfo={versionInfo}
/>,
)
expect(mockPluginManifestToCardPluginProps).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
plugin_id: 'manifest-plugin-id',
from: 'package',
}))
})
})
// Covers GitHub dependency fetching success and failure flows.
describe('GitHubItem', () => {
it('should show loading until the GitHub package metadata arrives', () => {
render(
<GitHubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={{
type: 'github',
value: {
repo: 'org/repo',
version: '1.0.0',
package: 'tool',
},
}}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={vi.fn()}
/>,
)
expect(screen.getByText('loading')).toBeInTheDocument()
})
it('should hydrate the loaded item and notify the parent after a successful fetch', async () => {
const onFetchedPayload = vi.fn()
const onCheckedChange = vi.fn()
mockUseUploadGitHub.mockReturnValue({
data: {
manifest: { plugin_unique_identifier: 'fetched-plugin-id' },
unique_identifier: 'github-uid',
},
error: undefined,
} as unknown as ReturnType<typeof useUploadGitHub>)
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin({
plugin_id: 'fetched-plugin-id',
}))
render(
<GitHubItem
checked={false}
onCheckedChange={onCheckedChange}
dependency={{
type: 'github',
value: {
repo: 'org/repo',
release: '1.2.3',
packages: 'tool',
},
}}
versionInfo={versionInfo}
onFetchedPayload={onFetchedPayload}
onFetchError={vi.fn()}
/>,
)
await waitFor(() => expect(onFetchedPayload).toHaveBeenCalledWith(expect.objectContaining({
plugin_id: 'github-uid',
})))
fireEvent.click(screen.getByRole('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(expect.objectContaining({
plugin_id: 'github-uid',
from: 'github',
}))
})
it('should notify the parent when the GitHub fetch fails', async () => {
const onFetchError = vi.fn()
mockUseUploadGitHub.mockReturnValue({
data: undefined,
error: new Error('failed'),
} as unknown as ReturnType<typeof useUploadGitHub>)
render(
<GitHubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={{
type: 'github',
value: {
repo: 'org/repo',
version: '1.0.0',
package: 'tool',
},
}}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={onFetchError}
/>,
)
await waitFor(() => expect(onFetchError).toHaveBeenCalledTimes(1))
})
})
})

View File

@ -0,0 +1,115 @@
import type { Plugin, VersionProps } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import LoadedItem from '../loaded-item'
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
let mockCanInstall = true
vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({
getIconUrl: mockGetIconUrl,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
default: () => ({
canInstall: mockCanInstall,
}),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, titleLeft, limitedInstall }: { payload: Plugin, titleLeft?: React.ReactNode, limitedInstall?: boolean }) => (
<div data-testid="plugin-card" data-icon={payload.icon} data-limited-install={String(!!limitedInstall)}>
{titleLeft}
<span>{payload.name}</span>
</div>
),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
name: 'Plugin Name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'github',
...overrides,
})
const versionInfo: VersionProps = {
hasInstalled: false,
toInstallVersion: '0.0.1',
}
describe('LoadedItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanInstall = true
})
// Covers local icon resolution and checkbox interaction.
describe('local payloads', () => {
it('should resolve local icons and emit the plugin when checked', () => {
const payload = createPlugin()
const onCheckedChange = vi.fn()
render(
<LoadedItem
checked={false}
onCheckedChange={onCheckedChange}
payload={payload}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
expect(screen.getAllByText('1.0.0').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(payload)
})
})
// Covers marketplace icon resolution and install-limit behavior.
describe('marketplace payloads', () => {
it('should use marketplace icon URLs and disable selection when install is limited', () => {
mockCanInstall = false
render(
<LoadedItem
checked
isFromMarketPlace
onCheckedChange={vi.fn()}
payload={createPlugin({
org: 'market-org',
name: 'market-plugin',
})}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute(
'data-icon',
`${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`,
)
expect(screen.getByTestId('plugin-card')).toHaveAttribute('data-limited-install', 'true')
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true')
})
})
})

View File

@ -0,0 +1,82 @@
import type { Plugin, VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import MarketPlaceItem from '../marketplace-item'
vi.mock('@/app/components/plugins/install-plugin/base/loading', () => ({
default: () => <div>loading</div>,
}))
vi.mock('../loaded-item', () => ({
default: ({ payload, isFromMarketPlace }: { payload: Plugin, isFromMarketPlace?: boolean }) => (
<div data-testid="loaded-item" data-version={payload.version} data-marketplace={String(!!isFromMarketPlace)}>
{payload.name}
</div>
),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
name: 'Plugin Name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
...overrides,
})
const versionInfo: VersionProps = {
hasInstalled: false,
toInstallVersion: '0.0.1',
}
describe('MarketPlaceItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers the loading placeholder when payload metadata has not arrived.
it('should render loading when the marketplace payload is missing', () => {
render(
<MarketPlaceItem
checked={false}
onCheckedChange={vi.fn()}
payload={undefined}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByText('loading')).toBeInTheDocument()
})
// Covers payload version override and marketplace forwarding.
it('should forward the marketplace payload with the requested version', () => {
render(
<MarketPlaceItem
checked={false}
onCheckedChange={vi.fn()}
payload={createPlugin({ version: '1.0.0' })}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-version', '2.0.0')
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-marketplace', 'true')
})
})

View File

@ -0,0 +1,106 @@
import type { PackageDependency, Plugin, VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import PackageItem from '../package-item'
const mockPluginManifestToCardPluginProps = vi.fn()
vi.mock('@/app/components/plugins/install-plugin/base/loading-error', () => ({
default: () => <div>loading-error</div>,
}))
vi.mock('@/app/components/plugins/install-plugin/utils', () => ({
pluginManifestToCardPluginProps: (...args: unknown[]) => mockPluginManifestToCardPluginProps(...args),
}))
vi.mock('../loaded-item', () => ({
default: ({ payload, isFromMarketPlace }: { payload: Plugin, isFromMarketPlace?: boolean }) => (
<div data-testid="loaded-item" data-plugin-id={payload.plugin_id} data-from={payload.from} data-marketplace={String(!!isFromMarketPlace)}>
{payload.name}
</div>
),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
name: 'Plugin Name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'package',
...overrides,
})
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
type: 'package',
value: {
unique_identifier: 'package-plugin-uid',
manifest: {
plugin_unique_identifier: 'package-plugin-uid',
} as PackageDependency['value']['manifest'],
},
...overrides,
})
const versionInfo: VersionProps = {
hasInstalled: false,
toInstallVersion: '0.0.1',
}
describe('PackageItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPluginManifestToCardPluginProps.mockReturnValue(createPlugin())
})
// Covers the missing-manifest fallback.
it('should render a loading error when the package manifest is missing', () => {
render(
<PackageItem
checked={false}
onCheckedChange={vi.fn()}
payload={createPackageDependency({
value: {
unique_identifier: 'missing-manifest',
manifest: undefined as unknown as PackageDependency['value']['manifest'],
},
})}
versionInfo={versionInfo}
/>,
)
expect(screen.getByText('loading-error')).toBeInTheDocument()
})
// Covers manifest transformation and prop forwarding into LoadedItem.
it('should transform the package manifest and forward marketplace origin when requested', () => {
render(
<PackageItem
checked
onCheckedChange={vi.fn()}
payload={createPackageDependency()}
isFromMarketPlace
versionInfo={versionInfo}
/>,
)
expect(mockPluginManifestToCardPluginProps).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-plugin-id', 'plugin-id')
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-from', 'package')
expect(screen.getByTestId('loaded-item')).toHaveAttribute('data-marketplace', 'true')
})
})

View File

@ -0,0 +1,117 @@
import type { InstallStatus, Plugin } from '../../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Installed from '../installed'
const mockGetIconUrl = vi.fn((icon: string) => `https://icons.example/${icon}`)
vi.mock('../../../base/use-get-icon', () => ({
default: () => ({
getIconUrl: mockGetIconUrl,
}),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, installed, installFailed, titleLeft }: { payload: Plugin, installed?: boolean, installFailed?: boolean, titleLeft?: React.ReactNode }) => (
<div
data-testid="plugin-card"
data-icon={payload.icon}
data-installed={String(!!installed)}
data-install-failed={String(!!installFailed)}
>
{titleLeft}
<span>{payload.name}</span>
</div>
),
}))
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
name: 'Plugin Name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'github',
...overrides,
})
describe('Installed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers marketplace/local icon resolution and status flags.
describe('rendering', () => {
it('should render installed cards with the correct icon source and status flags', () => {
const list = [
createPlugin({ from: 'marketplace', org: 'market-org', name: 'market-plugin' }),
createPlugin({ plugin_id: 'plugin-2', name: 'Local Plugin', from: 'github' }),
]
const installStatus: InstallStatus[] = [
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: false },
]
render(
<Installed
list={list}
installStatus={installStatus}
onCancel={vi.fn()}
/>,
)
const cards = screen.getAllByTestId('plugin-card')
expect(cards[0]).toHaveAttribute('data-icon', `${MARKETPLACE_API_PREFIX}/plugins/market-org/market-plugin/icon`)
expect(cards[0]).toHaveAttribute('data-installed', 'true')
expect(cards[1]).toHaveAttribute('data-icon', 'https://icons.example/icon.png')
expect(cards[1]).toHaveAttribute('data-install-failed', 'true')
expect(screen.getAllByText('1.0.0').length).toBeGreaterThan(0)
})
})
// Covers footer button visibility and close behavior.
describe('actions', () => {
it('should call onCancel when clicking close', () => {
const onCancel = vi.fn()
render(
<Installed
list={[createPlugin()]}
installStatus={[{ success: true, isFromMarketPlace: false }]}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should hide the footer button when isHideButton is true', () => {
render(
<Installed
list={[createPlugin()]}
installStatus={[{ success: true, isFromMarketPlace: false }]}
onCancel={vi.fn()}
isHideButton
/>,
)
expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument()
})
})
})

View File

@ -1,688 +1,304 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
import type { Dependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
import { defaultSystemFeatures } from '@/types/feature'
import { getPluginKey, useInstallMultiState } from '../use-install-multi-state'
let mockMarketplaceData: ReturnType<typeof createMarketplaceApiData> | null = null
let mockMarketplaceError: Error | null = null
let mockInstalledInfo: Record<string, VersionInfo> = {}
let mockCanInstall = true
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseFetchPluginsInMarketPlaceByInfo = vi.mocked(useFetchPluginsInMarketPlaceByInfo)
const mockUseCheckInstalled = vi.mocked(useCheckInstalled)
const mockPluginInstallLimit = vi.mocked(pluginInstallLimit)
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: () => ({
isLoading: false,
data: mockMarketplaceData,
error: mockMarketplaceError,
}),
useFetchPluginsInMarketPlaceByInfo: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({
installedInfo: mockInstalledInfo,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({}),
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: mockCanInstall }),
pluginInstallLimit: vi.fn(),
}))
const createMockPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'test-org',
name: 'Test Plugin',
plugin_id: 'test-plugin-id',
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'tool',
org: 'plugin-org',
author: 'plugin-author',
name: 'plugin-name',
plugin_id: 'plugin-id',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'test-pkg-id',
latest_package_identifier: 'pkg-id',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Test Plugin' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/test/plugin',
category: PluginCategoryEnum.tool,
install_count: 100,
verified: false,
label: { 'en-US': 'Plugin Name' } as Plugin['label'],
brief: { 'en-US': 'Plugin brief' } as Plugin['brief'],
description: { 'en-US': 'Plugin description' } as Plugin['description'],
introduction: '',
repository: '',
category: 'tool' as Plugin['category'],
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'community' },
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
...overrides,
})
const createPackageDependency = (index: number) => ({
const createPackageDependency = (overrides: Partial<PackageDependency> = {}): PackageDependency => ({
type: 'package',
value: {
unique_identifier: `package-plugin-${index}-uid`,
unique_identifier: 'package-plugin-uid',
manifest: {
plugin_unique_identifier: `package-plugin-${index}-uid`,
plugin_unique_identifier: 'package-plugin-uid',
version: '1.0.0',
author: 'test-author',
icon: 'icon.png',
name: `Package Plugin ${index}`,
category: PluginCategoryEnum.tool,
label: { 'en-US': `Package Plugin ${index}` },
description: { 'en-US': 'Test package plugin' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {},
},
author: 'package-org',
name: 'Package Plugin',
} as PackageDependency['value']['manifest'],
},
} as unknown as PackageDependency)
const createMarketplaceDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: `test-org/plugin-${index}:1.0.0`,
plugin_unique_identifier: `plugin-${index}`,
version: '1.0.0',
},
})
const createGitHubDependency = (index: number): GitHubItemAndMarketPlaceDependency => ({
type: 'github',
value: {
repo: `test-org/plugin-${index}`,
version: 'v1.0.0',
package: `plugin-${index}.zip`,
},
})
const createMarketplaceApiData = (indexes: number[]) => ({
data: {
list: indexes.map(i => ({
plugin: {
plugin_id: `test-org/plugin-${i}`,
org: 'test-org',
name: `Test Plugin ${i}`,
version: '1.0.0',
latest_version: '1.0.0',
},
version: {
unique_identifier: `plugin-${i}-uid`,
},
})),
},
})
const createDefaultParams = (overrides = {}) => ({
allPlugins: [createPackageDependency(0)] as Dependency[],
selectedPlugins: [] as Plugin[],
onSelect: vi.fn(),
onLoadedAllPlugin: vi.fn(),
...overrides,
})
// ==================== getPluginKey Tests ====================
describe('getPluginKey', () => {
it('should return org/name when org is available', () => {
const plugin = createMockPlugin({ org: 'my-org', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
})
it('should fall back to author when org is not available', () => {
const plugin = createMockPlugin({ org: undefined, author: 'my-author', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-author/my-plugin')
})
it('should prefer org over author when both exist', () => {
const plugin = createMockPlugin({ org: 'my-org', author: 'my-author', name: 'my-plugin' })
expect(getPluginKey(plugin)).toBe('my-org/my-plugin')
})
it('should handle undefined plugin', () => {
expect(getPluginKey(undefined)).toBe('undefined/undefined')
})
})
// ==================== useInstallMultiState Tests ====================
describe('useInstallMultiState', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData = null
mockMarketplaceError = null
mockInstalledInfo = {}
mockCanInstall = true
mockUseGlobalPublicStore.mockImplementation((selector) => {
return selector({
systemFeatures: defaultSystemFeatures,
setSystemFeatures: vi.fn(),
})
})
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
isLoading: false,
data: null,
error: null,
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
mockUseCheckInstalled.mockReturnValue({
installedInfo: {},
isLoading: false,
error: null,
} as ReturnType<typeof useCheckInstalled>)
mockPluginInstallLimit.mockReturnValue({ canInstall: true })
})
// ==================== Initial State ====================
describe('Initial State', () => {
it('should initialize plugins from package dependencies', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[0]?.plugin_id).toBe('package-plugin-0-uid')
})
it('should have slots for all dependencies even when no packages exist', () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
// Array has slots for all dependencies, but unresolved ones are undefined
expect(result.current.plugins).toHaveLength(1)
expect(result.current.plugins[0]).toBeUndefined()
})
it('should return undefined for non-package items in mixed dependencies', () => {
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[1]).toBeUndefined()
})
it('should start with empty errorIndexes', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.errorIndexes).toEqual([])
// Covers the exported plugin key helper.
describe('getPluginKey', () => {
it('should derive keys from org/name and fall back to author/name', () => {
expect(getPluginKey(createPlugin({ org: 'langgenius', name: 'search' }))).toBe('langgenius/search')
expect(getPluginKey(createPlugin({ org: '', author: 'author', name: 'plugin' }))).toBe('author/plugin')
})
})
// ==================== Marketplace Data Sync ====================
describe('Marketplace Data Sync', () => {
it('should update plugins when marketplace data loads by ID', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[0]?.version).toBe('1.0.0')
})
})
it('should update plugins when marketplace data loads by meta', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
// The "by meta" effect sets plugin_id from version.unique_identifier
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
})
})
it('should add to errorIndexes when marketplace item not found in response', async () => {
mockMarketplaceData = { data: { list: [] } }
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should handle multiple marketplace plugins', async () => {
mockMarketplaceData = createMarketplaceApiData([0, 1])
const params = createDefaultParams({
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.plugins[1]).toBeDefined()
})
})
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
mockMarketplaceData = {
data: {
list: [{
plugin: {
plugin_id: 'test-org/plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '',
latest_version: '2.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
}],
},
}
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]?.version).toBe('2.0.0')
})
})
it('should resolve marketplace dependency from organization and plugin fields', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
organization: 'test-org',
plugin: 'plugin-0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.errorIndexes).not.toContain(0)
})
})
})
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'invalid-identifier',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier is missing', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark all marketplace indexes as errors on fetch failure', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [
createMarketplaceDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
expect(result.current.errorIndexes).toContain(1)
})
})
it('should not affect non-marketplace indexes on marketplace fetch error', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createMarketplaceDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(1)
expect(result.current.errorIndexes).not.toContain(0)
})
})
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
const params = createDefaultParams({ allPlugins })
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== Loaded All Data Notification ====================
describe('Loaded All Data Notification', () => {
it('should call onLoadedAllPlugin when all data loaded', async () => {
const params = createDefaultParams()
renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(params.onLoadedAllPlugin).toHaveBeenCalledWith(mockInstalledInfo)
})
})
it('should not call onLoadedAllPlugin when not all plugins resolved', () => {
// GitHub plugin not fetched yet → isLoadedAllData = false
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
renderHook(() => useInstallMultiState(params))
expect(params.onLoadedAllPlugin).not.toHaveBeenCalled()
})
it('should call onLoadedAllPlugin after all errors are counted', async () => {
mockMarketplaceError = new Error('Fetch failed')
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
renderHook(() => useInstallMultiState(params))
// Error fills errorIndexes → isLoadedAllData becomes true
await waitFor(() => {
expect(params.onLoadedAllPlugin).toHaveBeenCalled()
})
})
})
// ==================== handleGitHubPluginFetched ====================
describe('handleGitHubPluginFetched', () => {
it('should update plugin at the specified index', async () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-0' })
await act(async () => {
result.current.handleGitHubPluginFetched(0)(mockPlugin)
})
expect(result.current.plugins[0]).toEqual(mockPlugin)
})
it('should not affect other plugin slots', async () => {
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const originalPlugin0 = result.current.plugins[0]
const mockPlugin = createMockPlugin({ plugin_id: 'github-plugin-1' })
await act(async () => {
result.current.handleGitHubPluginFetched(1)(mockPlugin)
})
expect(result.current.plugins[0]).toEqual(originalPlugin0)
expect(result.current.plugins[1]).toEqual(mockPlugin)
})
})
// ==================== handleGitHubPluginFetchError ====================
describe('handleGitHubPluginFetchError', () => {
it('should add index to errorIndexes', async () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleGitHubPluginFetchError(0)()
})
expect(result.current.errorIndexes).toContain(0)
})
it('should accumulate multiple error indexes without stale closure', async () => {
const params = createDefaultParams({
allPlugins: [
createGitHubDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleGitHubPluginFetchError(0)()
})
await act(async () => {
result.current.handleGitHubPluginFetchError(1)()
})
expect(result.current.errorIndexes).toContain(0)
expect(result.current.errorIndexes).toContain(1)
})
})
// ==================== getVersionInfo ====================
describe('getVersionInfo', () => {
it('should return hasInstalled false when plugin not installed', () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
const info = result.current.getVersionInfo('unknown/plugin')
expect(info.hasInstalled).toBe(false)
expect(info.installedVersion).toBeUndefined()
expect(info.toInstallVersion).toBe('')
})
it('should return hasInstalled true with version when installed', () => {
mockInstalledInfo = {
'test-author/Package Plugin 0': {
installedId: 'installed-1',
// Covers package + marketplace aggregation and parent notification when loading completes.
describe('aggregation', () => {
it('should merge package and marketplace plugins and notify when all install info is ready', async () => {
const onLoadedAllPlugin = vi.fn()
const installedInfo: Record<string, VersionInfo> = {
'package-org/Package Plugin': {
installedId: 'installed-id',
installedVersion: '0.9.0',
uniqueIdentifier: 'uid-1',
uniqueIdentifier: 'package-plugin-uid',
},
}
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
isLoading: false,
data: {
data: {
list: [
{
plugin: createPlugin({
plugin_id: 'lang/plugin-1',
org: 'lang',
name: 'plugin-1',
}),
},
],
},
},
error: null,
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
mockUseCheckInstalled.mockReturnValue({
installedInfo,
isLoading: false,
error: null,
} as ReturnType<typeof useCheckInstalled>)
const info = result.current.getVersionInfo('test-author/Package Plugin 0')
const { result } = renderHook(() => useInstallMultiState({
allPlugins: [
createPackageDependency(),
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'lang/plugin-1:1.0.0',
},
},
] as Dependency[],
selectedPlugins: [],
onSelect: vi.fn(),
onLoadedAllPlugin,
}))
expect(info.hasInstalled).toBe(true)
expect(info.installedVersion).toBe('0.9.0')
await waitFor(() => expect(onLoadedAllPlugin).toHaveBeenCalledWith(installedInfo))
expect(result.current.plugins[0]).toEqual(expect.objectContaining({
plugin_id: 'package-plugin-uid',
name: 'Package Plugin',
}))
expect(result.current.plugins[1]).toEqual(expect.objectContaining({
plugin_id: 'lang/plugin-1',
name: 'plugin-1',
from: 'marketplace',
}))
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== handleSelect ====================
describe('handleSelect', () => {
it('should call onSelect with plugin, index, and installable count', async () => {
const params = createDefaultParams()
const { result } = renderHook(() => useInstallMultiState(params))
// Covers github callbacks, selection bookkeeping, and installability filtering.
describe('github state', () => {
it('should track github payloads, errors, and installable selections', () => {
const onSelect = vi.fn()
mockPluginInstallLimit.mockImplementation(plugin => ({
canInstall: plugin?.name !== 'Blocked Plugin',
}))
await act(async () => {
const selectedPackage = createPlugin({
plugin_id: 'package-plugin-uid',
name: 'Package Plugin',
})
const { result } = renderHook(() => useInstallMultiState({
allPlugins: [
{
type: 'github',
value: {
repo: 'org/repo',
version: '1.0.0',
package: 'tool',
},
},
createPackageDependency(),
] as Dependency[],
selectedPlugins: [selectedPackage],
onSelect,
onLoadedAllPlugin: vi.fn(),
}))
act(() => {
result.current.handleGitHubPluginFetched(0)(createPlugin({
plugin_id: 'github-plugin-id',
org: 'github-org',
name: 'Blocked Plugin',
from: 'github',
}))
})
act(() => {
result.current.handleGitHubPluginFetchError(0)()
})
act(() => {
result.current.handleSelect(0)()
})
expect(params.onSelect).toHaveBeenCalledWith(
result.current.plugins[0],
0,
expect.any(Number),
)
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
plugin_id: 'github-plugin-id',
}), 0, 1)
expect(result.current.isPluginSelected(1)).toBe(true)
expect(result.current.errorIndexes).toEqual([0])
expect(result.current.getInstallablePlugins()).toEqual({
selectedIndexes: [1],
installablePlugins: [
expect.objectContaining({
plugin_id: 'package-plugin-uid',
}),
],
})
})
it('should filter installable plugins using pluginInstallLimit', async () => {
const params = createDefaultParams({
it('should expose empty version info and skip unloaded plugins when no package dependency exists', () => {
const { result } = renderHook(() => useInstallMultiState({
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
{
type: 'github',
value: {
repo: 'org/unloaded',
version: '1.0.0',
package: 'tool',
},
},
] as Dependency[],
selectedPlugins: [],
onSelect: vi.fn(),
onLoadedAllPlugin: vi.fn(),
}))
expect(result.current.plugins).toEqual([undefined])
expect(result.current.getVersionInfo('missing/plugin')).toEqual({
hasInstalled: false,
installedVersion: undefined,
toInstallVersion: '',
})
const { result } = renderHook(() => useInstallMultiState(params))
await act(async () => {
result.current.handleSelect(0)()
expect(result.current.getInstallablePlugins()).toEqual({
selectedIndexes: [],
installablePlugins: [],
})
// mockCanInstall is true, so all 2 plugins are installable
expect(params.onSelect).toHaveBeenCalledWith(
expect.anything(),
0,
2,
)
})
})
// ==================== isPluginSelected ====================
describe('isPluginSelected', () => {
it('should return true when plugin is in selectedPlugins', () => {
const selectedPlugin = createMockPlugin({ plugin_id: 'package-plugin-0-uid' })
const params = createDefaultParams({
selectedPlugins: [selectedPlugin],
})
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.isPluginSelected(0)).toBe(true)
})
it('should return false when plugin is not in selectedPlugins', () => {
const params = createDefaultParams({ selectedPlugins: [] })
const { result } = renderHook(() => useInstallMultiState(params))
it('should mark malformed marketplace dependencies as errors and normalize explicit marketplace info', () => {
mockUseFetchPluginsInMarketPlaceByInfo.mockReturnValue({
isLoading: false,
data: null,
error: new Error('marketplace failed'),
} as unknown as ReturnType<typeof useFetchPluginsInMarketPlaceByInfo>)
expect(result.current.isPluginSelected(0)).toBe(false)
})
it('should return false when plugin at index is undefined', () => {
const params = createDefaultParams({
allPlugins: [createGitHubDependency(0)] as Dependency[],
selectedPlugins: [createMockPlugin()],
})
const { result } = renderHook(() => useInstallMultiState(params))
// plugins[0] is undefined (GitHub not yet fetched)
expect(result.current.isPluginSelected(0)).toBe(false)
})
})
// ==================== getInstallablePlugins ====================
describe('getInstallablePlugins', () => {
it('should return all plugins when canInstall is true', () => {
mockCanInstall = true
const params = createDefaultParams({
const { result } = renderHook(() => useInstallMultiState({
allPlugins: [
createPackageDependency(0),
createPackageDependency(1),
{
type: 'marketplace',
value: {},
},
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'broken',
},
},
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'org/',
},
},
{
type: 'marketplace',
value: {
organization: 'explicit-org',
plugin: 'explicit-plugin',
version: '2.0.0',
},
},
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
selectedPlugins: [],
onSelect: vi.fn(),
onLoadedAllPlugin: vi.fn(),
}))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
expect(installablePlugins).toHaveLength(2)
expect(selectedIndexes).toEqual([0, 1])
})
it('should return empty arrays when canInstall is false', () => {
mockCanInstall = false
const params = createDefaultParams({
allPlugins: [createPackageDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
expect(installablePlugins).toHaveLength(0)
expect(selectedIndexes).toEqual([])
})
it('should skip unloaded (undefined) plugins', () => {
mockCanInstall = true
const params = createDefaultParams({
allPlugins: [
createPackageDependency(0),
createGitHubDependency(1),
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
const { installablePlugins, selectedIndexes } = result.current.getInstallablePlugins()
// Only package plugin is loaded; GitHub not yet fetched
expect(installablePlugins).toHaveLength(1)
expect(selectedIndexes).toEqual([0])
expect(mockUseFetchPluginsInMarketPlaceByInfo).toHaveBeenCalledWith([
{
organization: 'explicit-org',
plugin: 'explicit-plugin',
version: '2.0.0',
},
])
expect(result.current.errorIndexes).toEqual([0, 1, 2, 3])
})
})
})

View File

@ -0,0 +1,143 @@
import type { SubGraphProps } from '../types'
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import { FlowType } from '@/types/common'
import SubGraph from '../index'
const mockSetParentAvailableVars = vi.fn()
const mockSetParentAvailableNodes = vi.fn()
const mockUseSubGraphNodes = vi.fn()
const mockSubGraphMain = vi.fn()
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="workflow-default-context">{children}</div>,
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({
children,
injectWorkflowStoreSliceFn,
}: {
children: React.ReactNode
injectWorkflowStoreSliceFn: unknown
}) => (
<div data-testid="workflow-context-provider" data-has-slice={String(Boolean(injectWorkflowStoreSliceFn))}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: {
setParentAvailableVars: typeof mockSetParentAvailableVars
setParentAvailableNodes: typeof mockSetParentAvailableNodes
}) => unknown) => selector({
setParentAvailableVars: mockSetParentAvailableVars,
setParentAvailableNodes: mockSetParentAvailableNodes,
}),
}))
vi.mock('../hooks', () => ({
useSubGraphNodes: (...args: unknown[]) => mockUseSubGraphNodes(...args),
}))
vi.mock('../components/sub-graph-main', () => ({
default: (props: unknown) => {
mockSubGraphMain(props)
return <div data-testid="sub-graph-main" />
},
}))
const nestedNodeConfig: NestedNodeConfig = {
extractor_node_id: 'extractor-1',
output_selector: ['extractor-1', 'output'],
null_strategy: NULL_STRATEGY.RAISE_ERROR,
default_value: '',
}
const parentAvailableNodes = [{ id: 'node-1' }] as Node[]
const parentAvailableVars: NodeOutPutVar[] = [{ nodeId: 'node-1', title: 'Node 1', vars: [] }]
const configsMap: HooksStoreShape['configsMap'] = {
flowId: 'flow-1',
flowType: FlowType.appFlow,
}
const sourceVariable = ['agent-1', 'context'] as ValueSelector
const extractorNode = {
id: 'extractor-1',
data: { prompt_template: { role: 'user', text: 'ignored' } },
} as Node<LLMNodeType>
const assembleProps: SubGraphProps = {
variant: 'assemble',
title: 'Assembler',
isOpen: true,
toolNodeId: 'tool-node',
paramKey: 'question',
parentAvailableNodes,
parentAvailableVars,
configsMap,
nestedNodeConfig,
onNestedNodeConfigChange: vi.fn(),
}
describe('SubGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSubGraphNodes.mockReturnValue({
nodes: [{ id: 'render-node' }],
edges: [{ id: 'render-edge' }],
})
})
it('should inject the sub-graph slice and sync parent availability data', () => {
render(<SubGraph {...assembleProps} />)
expect(mockSetParentAvailableVars).toHaveBeenCalledWith(assembleProps.parentAvailableVars)
expect(mockSetParentAvailableNodes).toHaveBeenCalledWith(assembleProps.parentAvailableNodes)
})
it('should render the assemble variant with the derived extractor node id', () => {
render(<SubGraph {...assembleProps} />)
expect(mockSubGraphMain).toHaveBeenCalledWith(expect.objectContaining({
variant: 'assemble',
title: 'Assembler',
extractorNodeId: 'tool-node_ext_question',
nodes: [{ id: 'render-node' }],
edges: [{ id: 'render-edge' }],
}))
})
it('should render the agent variant with forwarded nested node config props', () => {
const onNestedNodeConfigChange = vi.fn()
render(
<SubGraph
variant="agent"
isOpen={true}
toolNodeId="agent-tool"
paramKey="context"
toolParamValue="{{#agent-1.context#}}user prompt"
agentNodeId="agent-1"
agentName="Agent Runner"
sourceVariable={sourceVariable}
nestedNodeConfig={nestedNodeConfig}
onNestedNodeConfigChange={onNestedNodeConfigChange}
extractorNode={extractorNode}
/>,
)
expect(mockSubGraphMain).toHaveBeenCalledWith(expect.objectContaining({
variant: 'agent',
title: 'Agent Runner',
extractorNodeId: 'agent-tool_ext_context',
nestedNodeConfig,
onNestedNodeConfigChange,
}))
})
})

View File

@ -0,0 +1,111 @@
import type { AgentSubGraphProps, AssembleSubGraphProps } from '../types'
import type { NestedNodeConfig } from '@/app/components/workflow/nodes/_base/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Node, ValueSelector } from '@/app/components/workflow/types'
import { describe, expect, it, vi } from 'vitest'
import { NULL_STRATEGY } from '@/app/components/workflow/nodes/_base/constants'
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
import {
buildSubGraphEdges,
buildSubGraphExtractorDisplayNode,
buildSubGraphStartNode,
getSubGraphExtractorNodeId,
getSubGraphPromptText,
getSubGraphSourceTitle,
} from '../utils'
const nestedNodeConfig: NestedNodeConfig = {
extractor_node_id: 'extractor-1',
output_selector: ['extractor-1', 'output'],
null_strategy: NULL_STRATEGY.RAISE_ERROR,
default_value: '',
}
const sourceVariable = ['agent-1', 'context'] as ValueSelector
const assembleExtractorNode = {
id: 'extractor-1',
data: { selected: true },
} as Node<CodeNodeType>
const agentExtractorNode = {
id: 'extractor-1',
data: {
selected: true,
prompt_template: [
{ role: PromptRole.system, text: 'keep me' },
{ role: PromptRole.user, text: 'replace me', edition_type: EditionType.jinja2 },
],
},
} as Node<LLMNodeType>
const createAssembleProps = (): AssembleSubGraphProps => ({
variant: 'assemble',
title: 'Assembler',
isOpen: true,
toolNodeId: 'tool-node',
paramKey: 'question',
nestedNodeConfig,
onNestedNodeConfigChange: vi.fn(),
extractorNode: assembleExtractorNode,
})
const createAgentProps = (): AgentSubGraphProps => ({
variant: 'agent',
isOpen: true,
toolNodeId: 'agent-tool',
paramKey: 'context',
toolParamValue: '{{#agent-1.context#}}hello world',
agentNodeId: 'agent-1',
agentName: 'Agent Runner',
sourceVariable,
nestedNodeConfig,
onNestedNodeConfigChange: vi.fn(),
extractorNode: agentExtractorNode,
})
describe('sub-graph utils', () => {
it('should resolve titles and extractor ids from the sub-graph props', () => {
expect(getSubGraphSourceTitle(createAssembleProps())).toBe('Assembler')
expect(getSubGraphSourceTitle(createAgentProps())).toBe('Agent Runner')
expect(getSubGraphExtractorNodeId(createAgentProps())).toBe('agent-tool_ext_context')
})
it('should strip the injected agent context prefix from the prompt text', () => {
expect(getSubGraphPromptText(createAgentProps())).toBe('hello world')
expect(getSubGraphPromptText(createAssembleProps())).toBe('')
})
it('should build the correct start node shape for each variant', () => {
expect(buildSubGraphStartNode(createAssembleProps(), 'Assembler').data.iconType).toBe('assemble')
expect(buildSubGraphStartNode(createAgentProps(), 'Agent Runner').data.iconType).toBe('agent')
})
it('should apply the agent prompt text to the extractor display node', () => {
const extractorDisplayNode = buildSubGraphExtractorDisplayNode(createAgentProps(), 'hello world')
expect(extractorDisplayNode?.data.selected).toBe(false)
expect(extractorDisplayNode?.data.prompt_template).toEqual([
{ role: PromptRole.system, text: 'keep me' },
{ role: PromptRole.user, text: 'hello world', edition_type: EditionType.jinja2, jinja2_text: 'hello world' },
])
})
it('should preserve the assemble extractor payload without prompt rewrites', () => {
const extractorDisplayNode = buildSubGraphExtractorDisplayNode(createAssembleProps(), '')
expect(extractorDisplayNode?.data.selected).toBe(false)
expect((extractorDisplayNode?.data as Record<string, unknown> | undefined)?.prompt_template).toBeUndefined()
})
it('should build edges targeting the correct block type for each variant', () => {
const agentProps = createAgentProps()
const assembleProps = createAssembleProps()
const agentStartNode = buildSubGraphStartNode(agentProps, 'Agent Runner')
const assembleStartNode = buildSubGraphStartNode(assembleProps, 'Assembler')
const agentExtractor = buildSubGraphExtractorDisplayNode(agentProps, 'hello world')
const assembleExtractor = buildSubGraphExtractorDisplayNode(assembleProps, '')
expect(buildSubGraphEdges(agentProps, agentStartNode, agentExtractor)[0].data.targetType).toBe(BlockEnum.LLM)
expect(buildSubGraphEdges(assembleProps, assembleStartNode, assembleExtractor)[0].data.targetType).toBe(BlockEnum.Code)
})
})

View File

@ -0,0 +1,34 @@
import type { Edge, Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubGraphNodes } from '../use-sub-graph-nodes'
const mockInitialNodes = vi.fn()
const mockInitialEdges = vi.fn()
vi.mock('@/app/components/workflow/utils', () => ({
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
}))
describe('useSubGraphNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should process nodes and edges through the workflow initializers', () => {
const nodes = [{ id: 'node-1' }] as Node[]
const edges = [{ id: 'edge-1' }] as Edge[]
mockInitialNodes.mockReturnValue([{ id: 'processed-node' }])
mockInitialEdges.mockReturnValue([{ id: 'processed-edge' }])
const { result } = renderHook(() => useSubGraphNodes(nodes, edges))
expect(mockInitialNodes).toHaveBeenCalledWith(nodes, edges)
expect(mockInitialEdges).toHaveBeenCalledWith(edges, nodes)
expect(result.current).toEqual({
nodes: [{ id: 'processed-node' }],
edges: [{ id: 'processed-edge' }],
})
})
})

View File

@ -2,40 +2,28 @@ import type { FC } from 'react'
import type { Viewport } from 'reactflow'
import type { SubGraphProps } from './types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import type { PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
import { memo, useEffect, useMemo } from 'react'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
import SubGraphMain from './components/sub-graph-main'
import { useSubGraphNodes } from './hooks'
import { createSubGraphSlice } from './store'
import {
buildSubGraphEdges,
buildSubGraphExtractorDisplayNode,
buildSubGraphStartNode,
defaultViewport,
getSubGraphExtractorNodeId,
getSubGraphPromptText,
getSubGraphSourceTitle,
} from './utils'
const SUB_GRAPH_EDGE_GAP = 160
const SUB_GRAPH_ENTRY_POSITION = {
x: START_INITIAL_POSITION.x,
y: 150,
}
const SUB_GRAPH_EXTRACTOR_POSITION = {
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
y: SUB_GRAPH_ENTRY_POSITION.y,
}
const defaultViewport: Viewport = {
x: SUB_GRAPH_EDGE_GAP,
y: 50,
zoom: 1,
}
const resolvedDefaultViewport: Viewport = defaultViewport
const SubGraphContent: FC<SubGraphProps> = (props) => {
const {
isOpen,
toolNodeId,
paramKey,
toolParamValue,
parentAvailableNodes,
parentAvailableVars,
configsMap,
@ -46,10 +34,6 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
onPendingSingleRunHandled,
} = props
const isAgentVariant = props.variant === 'agent'
const sourceTitle = isAgentVariant ? (props.agentName || '') : (props.title || '')
const resolvedAgentNodeId = isAgentVariant ? props.agentNodeId : ''
const setParentAvailableVars = useStore(state => state.setParentAvailableVars)
const setParentAvailableNodes = useStore(state => state.setParentAvailableNodes)
@ -58,134 +42,10 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
setParentAvailableNodes?.(parentAvailableNodes || [])
}, [parentAvailableNodes, parentAvailableVars, setParentAvailableNodes, setParentAvailableVars])
const promptText = useMemo(() => {
if (!isAgentVariant || !toolParamValue)
return ''
// Reason: escape agent id before building a regex pattern.
const escapedAgentId = resolvedAgentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`)
return toolParamValue.replace(leadingPattern, '')
}, [isAgentVariant, resolvedAgentNodeId, toolParamValue])
const startNode = useMemo(() => {
if (!isAgentVariant) {
return {
id: 'subgraph-source',
type: CUSTOM_SUB_GRAPH_START_NODE,
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: sourceTitle,
desc: '',
selected: false,
iconType: 'assemble',
variables: [],
},
selected: false,
selectable: false,
draggable: false,
connectable: false,
focusable: false,
deletable: false,
}
}
return {
id: 'subgraph-source',
type: CUSTOM_SUB_GRAPH_START_NODE,
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: sourceTitle,
desc: '',
selected: false,
iconType: 'agent',
variables: [],
},
selected: false,
selectable: false,
draggable: false,
connectable: false,
focusable: false,
deletable: false,
}
}, [isAgentVariant, sourceTitle])
const extractorDisplayNode = useMemo(() => {
if (isAgentVariant) {
const extractorNode = props.extractorNode
if (!extractorNode)
return null
const applyPromptText = (item: PromptItem) => {
if (item.edition_type === EditionType.jinja2) {
return {
...item,
text: promptText,
jinja2_text: promptText,
}
}
return { ...item, text: promptText }
}
const nextPromptTemplate = (() => {
const template = extractorNode.data.prompt_template
if (!Array.isArray(template))
return applyPromptText(template as PromptItem)
const userIndex = template.findIndex(
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
)
if (userIndex >= 0) {
return template.map((item, index) => {
if (index !== userIndex)
return item
return applyPromptText(item as PromptItem)
}) as PromptTemplateItem[]
}
const useJinja = template.some(
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
)
const defaultUserPrompt: PromptItem = useJinja
? {
role: PromptRole.user,
text: promptText,
jinja2_text: promptText,
edition_type: EditionType.jinja2,
}
: { role: PromptRole.user, text: promptText }
return [...template, defaultUserPrompt] as PromptTemplateItem[]
})()
return {
...extractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...extractorNode.data,
selected: false,
prompt_template: nextPromptTemplate,
},
}
}
const extractorNode = props.extractorNode
if (!extractorNode)
return null
return {
...extractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...extractorNode.data,
selected: false,
},
}
}, [isAgentVariant, promptText, props.extractorNode])
const sourceTitle = useMemo(() => getSubGraphSourceTitle(props), [props])
const promptText = useMemo(() => getSubGraphPromptText(props), [props])
const startNode = useMemo(() => buildSubGraphStartNode(props, sourceTitle), [props, sourceTitle])
const extractorDisplayNode = useMemo(() => buildSubGraphExtractorDisplayNode(props, promptText), [props, promptText])
const nodesSource = useMemo(() => {
if (!extractorDisplayNode)
@ -194,32 +54,12 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
return [startNode, extractorDisplayNode]
}, [extractorDisplayNode, startNode])
const edgesSource = useMemo(() => {
if (!extractorDisplayNode)
return []
return [
{
id: `${startNode.id}-${extractorDisplayNode.id}`,
source: startNode.id,
sourceHandle: 'source',
target: extractorDisplayNode.id,
targetHandle: 'target',
type: 'custom',
selectable: false,
data: {
sourceType: BlockEnum.Start,
targetType: isAgentVariant ? BlockEnum.LLM : BlockEnum.Code,
_isTemp: true,
_isSubGraphTemp: true,
},
},
]
}, [extractorDisplayNode, isAgentVariant, startNode])
const edgesSource = useMemo(() => buildSubGraphEdges(props, startNode, extractorDisplayNode), [extractorDisplayNode, props, startNode])
const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource)
const extractorNodeId = getSubGraphExtractorNodeId(props)
if (isAgentVariant) {
if (props.variant === 'agent') {
return (
<WorkflowWithDefaultContext
nodes={nodes}
@ -229,9 +69,9 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
variant="agent"
nodes={nodes}
edges={edges}
viewport={defaultViewport}
viewport={resolvedDefaultViewport}
title={sourceTitle}
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
extractorNodeId={extractorNodeId}
configsMap={configsMap}
isOpen={isOpen}
pendingSingleRun={pendingSingleRun}
@ -255,9 +95,9 @@ const SubGraphContent: FC<SubGraphProps> = (props) => {
variant="assemble"
nodes={nodes}
edges={edges}
viewport={defaultViewport}
viewport={resolvedDefaultViewport}
title={sourceTitle}
extractorNodeId={`${toolNodeId}_ext_${paramKey}`}
extractorNodeId={extractorNodeId}
configsMap={configsMap}
isOpen={isOpen}
pendingSingleRun={pendingSingleRun}

View File

@ -0,0 +1,28 @@
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { describe, expect, it } from 'vitest'
import { createSubGraphSlice } from '../index'
describe('createSubGraphSlice', () => {
it('should initialize and update parent availability state', () => {
let state = {} as ReturnType<typeof createSubGraphSlice>
const set = (updater: (current: ReturnType<typeof createSubGraphSlice>) => Partial<ReturnType<typeof createSubGraphSlice>>) => {
state = {
...state,
...updater(state),
}
}
state = createSubGraphSlice(set as never, (() => state) as never, {} as never)
expect(state.parentAvailableVars).toEqual([])
expect(state.parentAvailableNodes).toEqual([])
const parentAvailableVars: NodeOutPutVar[] = [{ nodeId: 'node-1', title: 'Node 1', vars: [] }]
state.setParentAvailableVars(parentAvailableVars)
expect(state.parentAvailableVars).toEqual(parentAvailableVars)
const parentAvailableNodes = [{ id: 'node-1' }] as Node[]
state.setParentAvailableNodes(parentAvailableNodes)
expect(state.parentAvailableNodes).toEqual(parentAvailableNodes)
})
})

View File

@ -0,0 +1,169 @@
import type { AgentSubGraphProps, AssembleSubGraphProps, SubGraphProps } from './types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Node, PromptItem, PromptTemplateItem } from '@/app/components/workflow/types'
import { NODE_WIDTH_X_OFFSET, START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { CUSTOM_SUB_GRAPH_START_NODE } from '@/app/components/workflow/nodes/sub-graph-start/constants'
import { BlockEnum, EditionType, isPromptMessageContext, PromptRole } from '@/app/components/workflow/types'
const SUB_GRAPH_EDGE_GAP = 160
export const defaultViewport = {
x: SUB_GRAPH_EDGE_GAP,
y: 50,
zoom: 1,
} as const
export const SUB_GRAPH_ENTRY_POSITION = {
x: START_INITIAL_POSITION.x,
y: 150,
} as const
export const SUB_GRAPH_EXTRACTOR_POSITION = {
x: SUB_GRAPH_ENTRY_POSITION.x + NODE_WIDTH_X_OFFSET - SUB_GRAPH_EDGE_GAP,
y: SUB_GRAPH_ENTRY_POSITION.y,
} as const
export const getSubGraphSourceTitle = (props: SubGraphProps) => (
props.variant === 'agent' ? (props.agentName || '') : (props.title || '')
)
export const getSubGraphExtractorNodeId = (props: SubGraphProps) => (
`${props.toolNodeId}_ext_${props.paramKey}`
)
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export const getSubGraphPromptText = (props: SubGraphProps) => {
if (props.variant !== 'agent' || !props.toolParamValue)
return ''
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapeRegExp(props.agentNodeId)}\\.context[@#]\\}\\}`)
return props.toolParamValue.replace(leadingPattern, '')
}
export const buildSubGraphStartNode = (props: SubGraphProps, sourceTitle: string) => ({
id: 'subgraph-source',
type: CUSTOM_SUB_GRAPH_START_NODE,
position: SUB_GRAPH_ENTRY_POSITION,
data: {
type: BlockEnum.Start,
title: sourceTitle,
desc: '',
selected: false,
iconType: props.variant === 'agent' ? 'agent' : 'assemble',
variables: [],
},
selected: false,
selectable: false,
draggable: false,
connectable: false,
focusable: false,
deletable: false,
})
const applyPromptText = (item: PromptItem, promptText: string): PromptItem => {
if (item.edition_type === EditionType.jinja2) {
return {
...item,
text: promptText,
jinja2_text: promptText,
}
}
return { ...item, text: promptText }
}
const buildExtractorPromptTemplate = (template: PromptTemplateItem | PromptTemplateItem[], promptText: string) => {
if (!Array.isArray(template))
return applyPromptText(template as PromptItem, promptText)
const userIndex = template.findIndex(
item => !isPromptMessageContext(item) && (item as PromptItem).role === PromptRole.user,
)
if (userIndex >= 0) {
return template.map((item, index) => {
if (index !== userIndex)
return item
return applyPromptText(item as PromptItem, promptText)
}) as PromptTemplateItem[]
}
const useJinja = template.some(
item => !isPromptMessageContext(item) && (item as PromptItem).edition_type === EditionType.jinja2,
)
const defaultUserPrompt: PromptItem = useJinja
? {
role: PromptRole.user,
text: promptText,
jinja2_text: promptText,
edition_type: EditionType.jinja2,
}
: { role: PromptRole.user, text: promptText }
return [...template, defaultUserPrompt] as PromptTemplateItem[]
}
export function buildSubGraphExtractorDisplayNode(props: AgentSubGraphProps, promptText: string): Node<LLMNodeType> | null
export function buildSubGraphExtractorDisplayNode(props: AssembleSubGraphProps, promptText: string): Node<CodeNodeType> | null
export function buildSubGraphExtractorDisplayNode(props: SubGraphProps, promptText: string): Node<LLMNodeType> | Node<CodeNodeType> | null
export function buildSubGraphExtractorDisplayNode(props: SubGraphProps, promptText: string): Node<LLMNodeType> | Node<CodeNodeType> | null {
const extractorNode = props.extractorNode
if (!extractorNode)
return null
if (props.variant !== 'agent') {
const assembleExtractorNode = extractorNode as Node<CodeNodeType>
return {
...assembleExtractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...assembleExtractorNode.data,
selected: false,
},
}
}
const agentExtractorNode = extractorNode as Node<LLMNodeType>
return {
...agentExtractorNode,
hidden: false,
selected: false,
position: SUB_GRAPH_EXTRACTOR_POSITION,
data: {
...agentExtractorNode.data,
selected: false,
prompt_template: buildExtractorPromptTemplate(agentExtractorNode.data.prompt_template, promptText),
},
}
}
export const buildSubGraphEdges = (
props: SubGraphProps,
startNode: ReturnType<typeof buildSubGraphStartNode>,
extractorDisplayNode: Node<LLMNodeType> | Node<CodeNodeType> | null,
) => {
if (!extractorDisplayNode)
return []
return [
{
id: `${startNode.id}-${extractorDisplayNode.id}`,
source: startNode.id,
sourceHandle: 'source',
target: extractorDisplayNode.id,
targetHandle: 'target',
type: 'custom',
selectable: false,
data: {
sourceType: BlockEnum.Start,
targetType: props.variant === 'agent' ? BlockEnum.LLM : BlockEnum.Code,
_isTemp: true,
_isSubGraphTemp: true,
},
},
]
}

View File

@ -138,7 +138,7 @@ export const fetchPromptTemplate = ({
modelName,
hasSetDataSet,
}: { appMode: AppModeEnum, mode: ModelModeType, modelName: string, hasSetDataSet: boolean }) => {
return get<Promise<{ chat_prompt_config: ChatPromptConfig, completion_prompt_config: CompletionPromptConfig, stop: [] }>>('/app/prompt-templates', {
return get<{ chat_prompt_config: ChatPromptConfig, completion_prompt_config: CompletionPromptConfig, stop: string[] }>('/app/prompt-templates', {
params: {
app_mode: appMode,
model_mode: mode,