mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): replace editor and upload doubles with behavioral tests (#45077)
This commit is contained in:
1 parent
3c26fe8905
commit
c5ee60f1a1
4 files changed
+180
-155
No files matched your search
@@ -1,10 +1,17 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { act, render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { createDatasetFixture } from '@/test/fixtures/dataset'
|
||||
import {
|
||||
createDatasetConfigsFixture,
|
||||
createDebugConfigurationFixture,
|
||||
createModelConfigFixture,
|
||||
} from '@/test/fixtures/debug-configuration'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import { AgentStrategy, RETRIEVE_TYPE } from '@/types/app'
|
||||
import {
|
||||
useConfigFromDebugContext,
|
||||
useDebugWithSingleOrMultipleModel,
|
||||
@@ -12,15 +19,8 @@ import {
|
||||
useFormattingChangedSubscription,
|
||||
} from '../hooks'
|
||||
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
|
||||
vi.mock('@/context/debug-configuration', () => ({
|
||||
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
|
||||
}))
|
||||
|
||||
describe('configuration debug hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('configuration debug hooks', () => {
|
||||
})
|
||||
|
||||
it('should derive chat config data from the debug context', () => {
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
const configuration = createDebugConfigurationFixture({
|
||||
isAdvancedMode: false,
|
||||
modelConfig: createModelConfigFixture({
|
||||
agentConfig: {
|
||||
@@ -82,24 +82,26 @@ describe('configuration debug hooks', () => {
|
||||
more_like_this: { enabled: true },
|
||||
}),
|
||||
appId: 'app-1',
|
||||
promptMode: 'simple',
|
||||
promptMode: PromptMode.simple,
|
||||
speechToTextConfig: { enabled: false },
|
||||
introduction: 'hello',
|
||||
suggestedQuestions: ['how are you?'],
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
citationConfig: { enabled: true },
|
||||
moderationConfig: { enabled: false },
|
||||
chatPromptConfig: { prompt: [] },
|
||||
completionPromptConfig: { prompt: { text: 'completion' } },
|
||||
dataSets: [{ id: 'dataset-1' }],
|
||||
datasetConfigs: createDatasetConfigsFixture({ retrieval_model: 'multiple' }),
|
||||
visionConfig: { transfer_methods: ['local_file'], number_limits: 2 },
|
||||
annotationConfig: { enabled: false },
|
||||
dataSets: [createDatasetFixture({ id: 'dataset-1' })],
|
||||
datasetConfigs: createDatasetConfigsFixture({ retrieval_model: RETRIEVE_TYPE.multiWay }),
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' },
|
||||
isFunctionCall: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useConfigFromDebugContext())
|
||||
const { result } = renderHook(() => useConfigFromDebugContext(), {
|
||||
wrapper: ({ children }: PropsWithChildren) => (
|
||||
<DebugConfigurationContext.Provider value={configuration}>
|
||||
{children}
|
||||
</DebugConfigurationContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -117,26 +119,41 @@ describe('configuration debug hooks', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hasAnswer: true, formattingChanged: false, calls: 1 },
|
||||
{ hasAnswer: false, formattingChanged: false, calls: 0 },
|
||||
{ hasAnswer: true, formattingChanged: true, calls: 0 },
|
||||
{ hasAnswer: true, initiallyChanged: false, expected: 'Refresh required' },
|
||||
{ hasAnswer: false, initiallyChanged: false, expected: 'Up to date' },
|
||||
{ hasAnswer: true, initiallyChanged: true, expected: 'Refresh required' },
|
||||
])(
|
||||
'dispatches formatting changes only for existing answers requiring refresh: %j',
|
||||
({ hasAnswer, formattingChanged, calls }) => {
|
||||
const setFormattingChanged = vi.fn()
|
||||
mockUseDebugConfigurationContext.mockReturnValue({ formattingChanged, setFormattingChanged })
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useFormattingChangedSubscription([
|
||||
{ id: 'message-1', content: 'Hello', isAnswer: hasAnswer },
|
||||
])
|
||||
return useFormattingChangedDispatcher()
|
||||
},
|
||||
{ wrapper: EventEmitterContextProvider },
|
||||
'updates formatting state for the current conversation: %j',
|
||||
async ({ hasAnswer, initiallyChanged, expected }) => {
|
||||
function Conversation() {
|
||||
const dispatch = useFormattingChangedDispatcher()
|
||||
useFormattingChangedSubscription([
|
||||
{ id: 'message-1', content: 'Hello', isAnswer: hasAnswer },
|
||||
])
|
||||
return <button onClick={dispatch}>Change configuration</button>
|
||||
}
|
||||
function Session() {
|
||||
const [formattingChanged, setFormattingChanged] = useState(initiallyChanged)
|
||||
return (
|
||||
<DebugConfigurationContext.Provider
|
||||
value={createDebugConfigurationFixture({ formattingChanged, setFormattingChanged })}
|
||||
>
|
||||
<Conversation />
|
||||
<output>{formattingChanged ? 'Refresh required' : 'Up to date'}</output>
|
||||
</DebugConfigurationContext.Provider>
|
||||
)
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EventEmitterContextProvider>
|
||||
<Session />
|
||||
</EventEmitterContextProvider>,
|
||||
)
|
||||
act(() => result.current())
|
||||
expect(setFormattingChanged).toHaveBeenCalledTimes(calls)
|
||||
if (calls) expect(setFormattingChanged).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
initiallyChanged ? 'Refresh required' : 'Up to date',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Change configuration' }))
|
||||
expect(screen.getByRole('status')).toHaveTextContent(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
+91
-49
@@ -1,18 +1,22 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HumanInputForm from '../human-input-form'
|
||||
|
||||
const releaseGate = vi.hoisted(() => ({ enabled: true }))
|
||||
|
||||
// The uploader owns file state; only its multipart network transport is controlled.
|
||||
const { uploadRequest } = vi.hoisted(() => ({
|
||||
uploadRequest: vi.fn<(options: { data: FormData }) => Promise<unknown>>(),
|
||||
}))
|
||||
|
||||
// The build-time release flag has no runtime control, so vary that configuration boundary directly.
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
get ENABLE_HITL_IM() {
|
||||
@@ -20,38 +24,28 @@ vi.mock('@/config', async (importOriginal) => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
fileConfig,
|
||||
}: ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
|
||||
const single = fileConfig.number_limits === 1
|
||||
const update = (progress: number) =>
|
||||
onChange([
|
||||
{
|
||||
id: single ? 'file-2' : 'file-1',
|
||||
name: single ? 'main.png' : 'avatar.png',
|
||||
size: 128,
|
||||
type: 'image/png',
|
||||
progress,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: SupportUploadFileTypes.image,
|
||||
uploadedId: progress === 100 ? (single ? 'upload-file-2' : 'upload-file-1') : undefined,
|
||||
},
|
||||
])
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => update(50)}>
|
||||
{single ? 'Upload single file' : 'Upload file list'}
|
||||
</button>
|
||||
<button onClick={() => update(100)}>
|
||||
{single ? 'Finish single file' : 'Finish file list'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
},
|
||||
vi.mock('@/next/navigation', () => ({ useParams: () => ({}), usePathname: () => '/' }))
|
||||
vi.mock('@/service/base', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/base')>()),
|
||||
upload: uploadRequest,
|
||||
}))
|
||||
|
||||
const uploadResponse = (file: File) => ({
|
||||
id: file.name === 'main.png' ? 'upload-file-2' : 'upload-file-1',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
extension: 'png',
|
||||
created_at: 1,
|
||||
created_by: 'user-1',
|
||||
preview_url: null,
|
||||
source_url: '',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
uploadRequest.mockImplementation(async ({ data }) => uploadResponse(data.get('file') as File))
|
||||
})
|
||||
|
||||
describe('HumanInputForm', () => {
|
||||
beforeEach(() => {
|
||||
releaseGate.enabled = true
|
||||
@@ -70,7 +64,7 @@ describe('HumanInputForm', () => {
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'field1',
|
||||
default: { type: 'constant', value: 'initial', selector: [] },
|
||||
} as FormInputItem,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary },
|
||||
@@ -160,15 +154,24 @@ describe('HumanInputForm', () => {
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 5,
|
||||
},
|
||||
] as FormInputItem[],
|
||||
],
|
||||
}
|
||||
|
||||
render(<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />)
|
||||
const { container } = render(
|
||||
<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'field1' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'new value')
|
||||
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
|
||||
// The current file chooser has no label; target the native upload input.
|
||||
await user.upload(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"]')!,
|
||||
new File(['avatar'], 'avatar.png', { type: 'image/png' }),
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled())
|
||||
expect(screen.getByText('avatar.png')).toBeVisible()
|
||||
expect((uploadRequest.mock.calls[0]![0].data.get('file') as File).name).toBe('avatar.png')
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
@@ -218,28 +221,67 @@ describe('HumanInputForm', () => {
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
},
|
||||
] as FormInputItem[],
|
||||
],
|
||||
}
|
||||
|
||||
render(
|
||||
const singleUpload = createDeferred<ReturnType<typeof uploadResponse>>()
|
||||
const listUpload = createDeferred<ReturnType<typeof uploadResponse>>()
|
||||
const replacementUpload = createDeferred<ReturnType<typeof uploadResponse>>()
|
||||
let listAttempts = 0
|
||||
uploadRequest.mockImplementation(({ data }) => {
|
||||
const file = data.get('file') as File
|
||||
if (file.name === 'main.png') return singleUpload.promise
|
||||
return listAttempts++ === 0 ? listUpload.promise : replacementUpload.promise
|
||||
})
|
||||
const { container } = render(
|
||||
<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />,
|
||||
)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'field2' }))
|
||||
await user.click(screen.getByRole('option', { name: 'approved' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Upload single file' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Finish single file' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Upload file list' }))
|
||||
const singleFile = new File(['main'], 'main.png', { type: 'image/png' })
|
||||
const listFile = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||
// Native input.multiple distinguishes the single-file and file-list fields.
|
||||
await user.upload(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"]:not([multiple])')!,
|
||||
singleFile,
|
||||
)
|
||||
await screen.findByText('main.png')
|
||||
expect(submitButton).toBeDisabled()
|
||||
await user.click(submitButton)
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
await user.upload(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"][multiple]')!,
|
||||
listFile,
|
||||
)
|
||||
await screen.findByText('avatar.png')
|
||||
expect(submitButton).toBeDisabled()
|
||||
await user.click(submitButton)
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
await act(async () => listUpload.resolve(uploadResponse(listFile)))
|
||||
// A completed file list must not hide a still-pending single-file upload.
|
||||
expect(submitButton).toBeDisabled()
|
||||
await user.click(submitButton)
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
await act(async () => singleUpload.resolve(uploadResponse(singleFile)))
|
||||
await waitFor(() => expect(submitButton).toBeEnabled())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Finish file list' }))
|
||||
expect(submitButton).toBeEnabled()
|
||||
// Replace the completed list file through the real removal/upload controls.
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove avatar.png' }))
|
||||
expect(submitButton).toBeDisabled()
|
||||
await user.upload(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"][multiple]')!,
|
||||
listFile,
|
||||
)
|
||||
await screen.findByText('avatar.png')
|
||||
expect(submitButton).toBeDisabled()
|
||||
await user.click(submitButton)
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled()
|
||||
await act(async () => replacementUpload.resolve(uploadResponse(listFile)))
|
||||
await waitFor(() => expect(submitButton).toBeEnabled())
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
@@ -297,7 +339,7 @@ describe('HumanInputForm', () => {
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
},
|
||||
] as FormInputItem[],
|
||||
],
|
||||
}
|
||||
|
||||
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
|
||||
|
||||
+9
-38
@@ -1,59 +1,30 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import JsonImporter from '../json-importer'
|
||||
import { MittProvider, useMittContext, VisualEditorContext } from '../visual-editor/context'
|
||||
import { MittProvider, VisualEditorContext } from '../visual-editor/context'
|
||||
import { createVisualEditorStore } from '../visual-editor/store'
|
||||
|
||||
// Monaco editing is outside this test; parsing, depth validation and editor state remain real.
|
||||
vi.mock('../code-editor', () => ({
|
||||
default: ({ value, onUpdate }: { value: string; onUpdate: (value: string) => void }) => (
|
||||
<textarea
|
||||
aria-label="JSON input"
|
||||
value={value}
|
||||
onChange={(event) => onUpdate(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Use the shared external Monaco shim, keeping Dify's code editor real.
|
||||
function setup() {
|
||||
const store = createVisualEditorStore()
|
||||
const onSubmit = vi.fn()
|
||||
const onQuitEditing = vi.fn()
|
||||
function Subscriber() {
|
||||
useMittContext().useSubscribe('quitEditing', onQuitEditing)
|
||||
return null
|
||||
}
|
||||
render(
|
||||
<VisualEditorContext value={store}>
|
||||
<MittProvider>
|
||||
<Subscriber />
|
||||
<JsonImporter onSubmit={onSubmit} updateBtnWidth={vi.fn()} />
|
||||
</MittProvider>
|
||||
</VisualEditorContext>,
|
||||
)
|
||||
return { store, onSubmit, onQuitEditing, user: userEvent.setup() }
|
||||
return { onSubmit, user: userEvent.setup() }
|
||||
}
|
||||
const importButton = () => screen.getByRole('button', { name: /nodes.llm.jsonSchema.import/ })
|
||||
const submitButton = () => screen.getByRole('button', { name: /operation.submit/ })
|
||||
|
||||
it.each(['advancedEditing', 'isAddingNewField'] as const)(
|
||||
'notifies actual editor subscribers when opening after %s changes',
|
||||
async (flag) => {
|
||||
const { store, user, onQuitEditing } = setup()
|
||||
await user.click(importButton())
|
||||
expect(onQuitEditing).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: /operation.cancel/ }))
|
||||
act(() => store.setState({ [flag]: true }))
|
||||
await user.click(importButton())
|
||||
expect(onQuitEditing).toHaveBeenCalledExactlyOnceWith({})
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['[]', '42'])('rejects non-object JSON %s', async (value) => {
|
||||
const { user, onSubmit } = setup()
|
||||
await user.click(importButton())
|
||||
await user.click(screen.getByRole('textbox', { name: 'JSON input' }))
|
||||
await user.click(screen.getByTestId('monaco-editor'))
|
||||
await user.paste(value)
|
||||
await user.click(submitButton())
|
||||
expect(
|
||||
@@ -68,7 +39,7 @@ it('rejects objects exceeding the real depth limit and accepts a corrected objec
|
||||
let nested: Record<string, unknown> = { leaf: 'value' }
|
||||
for (let level = 0; level < JSON_SCHEMA_MAX_DEPTH; level++) nested = { nested }
|
||||
await user.click(importButton())
|
||||
const input = screen.getByRole('textbox', { name: 'JSON input' })
|
||||
const input = screen.getByTestId('monaco-editor')
|
||||
await user.click(input)
|
||||
await user.paste(JSON.stringify(nested))
|
||||
await user.click(submitButton())
|
||||
@@ -96,7 +67,7 @@ it.each([1, 2])(
|
||||
return { rows }
|
||||
}
|
||||
await user.click(importButton())
|
||||
const input = screen.getByRole('textbox', { name: 'JSON input' })
|
||||
const input = screen.getByTestId('monaco-editor')
|
||||
await user.click(input)
|
||||
await user.paste(JSON.stringify(exampleWithSchemaDepth(JSON_SCHEMA_MAX_DEPTH + 1)))
|
||||
await user.click(submitButton())
|
||||
@@ -126,7 +97,7 @@ it('preserves empty arrays, primitive arrays and first-item schema inference', a
|
||||
objectFirst: [{ leaf: 'first' }, laterItem],
|
||||
}
|
||||
await user.click(importButton())
|
||||
await user.click(screen.getByRole('textbox', { name: 'JSON input' }))
|
||||
await user.click(screen.getByTestId('monaco-editor'))
|
||||
await user.paste(JSON.stringify(example))
|
||||
await user.click(submitButton())
|
||||
expect(onSubmit).toHaveBeenCalledExactlyOnceWith(example)
|
||||
@@ -136,7 +107,7 @@ it('preserves empty arrays, primitive arrays and first-item schema inference', a
|
||||
it('reports malformed JSON and allows correction without replacing JSON.parse', async () => {
|
||||
const { user, onSubmit } = setup()
|
||||
await user.click(importButton())
|
||||
const input = screen.getByRole('textbox', { name: 'JSON input' })
|
||||
const input = screen.getByTestId('monaco-editor')
|
||||
await user.clear(input)
|
||||
await user.paste('{bad')
|
||||
await user.click(submitButton())
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TemplateTransformNodeType } from '../types'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -11,33 +10,26 @@ import { seedToolProviders } from '@/test/console/tool-providers'
|
||||
import { BlockEnum, VarType } from '../../../types'
|
||||
import Panel from '../panel'
|
||||
|
||||
// The external code editor is a text-input boundary; variable editing and configuration stay real.
|
||||
vi.mock(
|
||||
'@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars',
|
||||
() => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
headerRight,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
readOnly: boolean
|
||||
headerRight: ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{headerRight}
|
||||
<textarea
|
||||
aria-label="template-editor"
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
)
|
||||
// Only the external Monaco runtime is replaced; Dify's editor wrappers remain real.
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { readOnly?: boolean }
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="Template editor"
|
||||
value={value}
|
||||
readOnly={options.readOnly}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
loader: { config: vi.fn() },
|
||||
}))
|
||||
|
||||
const payload: TemplateTransformNodeType = {
|
||||
title: 'Template',
|
||||
@@ -99,7 +91,7 @@ it('renames real input variables and keeps the template synchronized through sub
|
||||
const variable = screen.getByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
|
||||
// One change event represents replacing the selected identifier, preserving a valid name throughout.
|
||||
fireEvent.change(variable, { target: { value: 'renamed_input' } })
|
||||
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveValue(
|
||||
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(
|
||||
'{{ renamed_input }}',
|
||||
)
|
||||
await user.click(
|
||||
@@ -111,7 +103,7 @@ it('renames real input variables and keeps the template synchronized through sub
|
||||
screen.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' }),
|
||||
).toHaveLength(2)
|
||||
expect(variable).toHaveValue('renamed_input')
|
||||
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveValue(
|
||||
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(
|
||||
'{{ renamed_input }}',
|
||||
)
|
||||
expect(
|
||||
@@ -119,14 +111,17 @@ it('renames real input variables and keeps the template synchronized through sub
|
||||
).toHaveAttribute('href', 'https://jinja.palletsprojects.com/en/3.1.x/templates/')
|
||||
})
|
||||
|
||||
it('keeps both variable names and the template readonly when editing is denied', () => {
|
||||
it('keeps both variable names and the template readonly when editing is denied', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(true)
|
||||
await user.type(screen.getByRole('textbox', { name: 'Template editor' }), 'changed')
|
||||
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveValue(payload.template)
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'common.operation.add workflow.nodes.templateTransform.inputVars',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'template-editor' })).toHaveAttribute('readonly')
|
||||
expect(screen.getByRole('textbox', { name: 'Template editor' })).toHaveAttribute('readonly')
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' }),
|
||||
).toBeDisabled()
|
||||
|
||||
Reference in new issue
Block a user