test(web): replace fake workflow controls with behavioral tests (#45025)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-29 03:27:11 +00:00
1 parent 1fc2ed94b6
commit e0af6d07aa
4 files changed
+540 -440

No files matched your search

@@ -1,163 +1,201 @@
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { Variable } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { VarType } from '@/app/components/workflow/types'
import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { renderWorkflowEditor } from '@/test/workflow-editor'
import VarList from '../var-list'
vi.mock('../var-reference-picker', () => ({
default: () => <button type="button">Variable value</button>,
const initialVariables: Variable[] = ['alpha', 'beta', 'gamma'].map((variable) => ({
variable,
value_selector: ['source', variable],
value_type: VarType.string,
}))
function SortableVariables({
initial = initialVariables,
readonly = false,
}: {
initial?: Variable[]
readonly?: boolean
}) {
const [variables, setVariables] = useState(initial)
return (
<>
<VarList nodeId="consumer" readonly={readonly} list={variables} onChange={setVariables} />
<output aria-label="Saved variables">{JSON.stringify(variables)}</output>
</>
)
}
const renderVariables = (props: Parameters<typeof SortableVariables>[0] = {}) => {
const sourceData: CodeNodeType = {
title: 'Source',
desc: '',
type: BlockEnum.Code,
code_language: CodeLanguage.javascript,
code: 'function main() { return {} }',
variables: [],
outputs: Object.fromEntries(
initialVariables.map(({ variable }) => [variable, { type: VarType.string, children: null }]),
),
}
const source = createNode({
id: 'source',
data: sourceData,
})
const consumerData: CodeNodeType = {
title: 'Consumer',
desc: '',
type: BlockEnum.Code,
code_language: CodeLanguage.javascript,
code: 'function main() { return {} }',
variables: initialVariables,
outputs: {},
}
const consumer = createNode({ id: 'consumer', data: consumerData })
return renderWorkflowEditor(<SortableVariables {...props} />, {
nodes: [source, consumer],
edges: [
createEdge({
source: 'source',
target: 'consumer',
data: { sourceType: BlockEnum.Code, targetType: BlockEnum.Code },
}),
],
initialStoreState: { isWorkflowDataLoaded: true },
reactFlowProps: {
edgesFocusable: false,
nodesFocusable: false,
proOptions: { hideAttribution: true },
},
})
}
const savedVariables = () => screen.getByRole('status', { name: 'Saved variables' })
describe('VarList', () => {
it('normalizes a variable name through its labeled input', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
function EditableVariable() {
const [list, setList] = useState<Variable[]>([
{
variable: 'input',
value_selector: ['node-id', 'value'],
value_type: VarType.string,
},
])
return (
<VarList
nodeId="node-id"
readonly={false}
list={list}
onChange={(next) => {
setList(next)
onChange(next)
}}
/>
)
}
render(<EditableVariable />)
renderVariables({ initial: [initialVariables[0]!] })
const input = screen.getByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
await user.clear(input)
await user.paste('renamed input')
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ variable: 'renamed_input' })])
expect(savedVariables()).toHaveTextContent(
JSON.stringify([{ ...initialVariables[0], variable: 'renamed_input' }]),
)
})
it('tabs through each variable from the reorder handle to its name, value actions and remove button', async () => {
const user = userEvent.setup()
renderVariables()
const handles = screen.getAllByRole('button', { pressed: false })
const names = screen.getAllByRole('textbox', {
name: 'workflow.common.variableNamePlaceholder',
})
const values = initialVariables.map(({ variable }) =>
screen.getByRole('button', { name: new RegExp(`Source.*${variable}.*string`) }),
)
const clearButtons = screen.getAllByRole('button', { name: 'common.operation.clear' })
const pickerFocusTargets = screen.getAllByRole('textbox', { name: '' })
const removeButtons = screen.getAllByRole('button', { name: 'common.operation.remove' })
for (let index = 0; index < initialVariables.length; index++) {
for (const control of [
handles[index],
names[index],
values[index],
clearButtons[index],
pickerFocusTargets[index],
removeButtons[index],
]) {
await user.tab()
expect(control).toHaveFocus()
}
}
await user.tab({ shift: true })
expect(pickerFocusTargets[2]).toHaveFocus()
await user.tab({ shift: true })
expect(clearButtons[2]).toHaveFocus()
await user.tab({ shift: true })
expect(values[2]).toHaveFocus()
await user.tab({ shift: true })
expect(names[2]).toHaveFocus()
await user.tab({ shift: true })
expect(handles[2]).toHaveFocus()
})
it('previews variable ordering, commits only on confirmation, and keeps the moved handle focused', async () => {
const user = userEvent.setup()
renderVariables()
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab()
expect(handle).toHaveFocus()
await user.keyboard('{Enter}{ArrowDown}')
expect(
screen
.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
.map((input) => (input as HTMLInputElement).value),
).toEqual(['beta', 'alpha', 'gamma'])
expect(savedVariables()).toHaveTextContent(JSON.stringify(initialVariables))
expect(screen.getByRole('button', { pressed: true })).toHaveFocus()
await user.keyboard('{Enter}')
expect(savedVariables()).toHaveTextContent(
JSON.stringify([initialVariables[1], initialVariables[0], initialVariables[2]]),
)
expect(screen.getAllByRole('button', { pressed: false })[1]).toHaveFocus()
})
it('cancels a variable reorder and leaves arrow keys inside name inputs available for editing', async () => {
const user = userEvent.setup()
renderVariables()
await user.tab()
await user.tab()
expect(
screen.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })[0],
).toHaveFocus()
await user.keyboard('{ArrowDown}')
expect(savedVariables()).toHaveTextContent(JSON.stringify(initialVariables))
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab({ shift: true })
expect(handle).toHaveFocus()
await user.keyboard(' {ArrowDown}{Escape}')
expect(
screen
.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
.map((input) => (input as HTMLInputElement).value),
).toEqual(['alpha', 'beta', 'gamma'])
expect(savedVariables()).toHaveTextContent(JSON.stringify(initialVariables))
expect(handle).toHaveFocus()
})
it('does not expose variable sorting in readonly mode', () => {
renderVariables({ readonly: true })
expect(screen.queryByRole('button', { pressed: false })).not.toBeInTheDocument()
expect(savedVariables()).toHaveTextContent(JSON.stringify(initialVariables))
})
it('cancels a preview before deleting the variable whose remove button was activated', async () => {
const user = userEvent.setup()
renderVariables()
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab()
expect(handle).toHaveFocus()
await user.keyboard('{Enter}{ArrowDown}')
await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!)
expect(savedVariables()).toHaveTextContent(
JSON.stringify([initialVariables[1], initialVariables[2]]),
)
expect(
screen
.getAllByRole('textbox', { name: 'workflow.common.variableNamePlaceholder' })
.map((input) => (input as HTMLInputElement).value),
).toEqual(['beta', 'gamma'])
})
})
const initialVariables: Variable[] = ['alpha', 'beta', 'gamma'].map((variable) => ({
variable,
value_selector: ['node-id', variable],
value_type: VarType.string,
}))
function SortableVariables({
onChange,
readonly = false,
}: {
onChange: (items: Variable[]) => void
readonly?: boolean
}) {
const [variables, setVariables] = useState(initialVariables)
return (
<VarList
nodeId="node-id"
readonly={readonly}
list={variables}
onChange={(items) => {
onChange(items)
setVariables(items)
}}
/>
)
}
it('tabs through each variable from the reorder handle to its name, value and remove button', async () => {
const user = userEvent.setup()
render(<SortableVariables onChange={vi.fn()} />)
const handles = screen.getAllByRole('button', { pressed: false })
const names = screen.getAllByRole('textbox')
const values = screen.getAllByRole('button', { name: 'Variable value' })
const removeButtons = screen.getAllByRole('button', { name: 'common.operation.remove' })
for (let index = 0; index < initialVariables.length; index++) {
for (const control of [handles[index], names[index], values[index], removeButtons[index]]) {
await user.tab()
expect(control).toHaveFocus()
}
}
await user.tab({ shift: true })
expect(values[2]).toHaveFocus()
await user.tab({ shift: true })
expect(names[2]).toHaveFocus()
await user.tab({ shift: true })
expect(handles[2]).toHaveFocus()
})
it('previews variable ordering, commits only on confirmation, and keeps the moved handle focused', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<SortableVariables onChange={onChange} />)
expect(onChange).not.toHaveBeenCalled()
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab()
expect(handle).toHaveFocus()
await user.keyboard('{Enter}{ArrowDown}')
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
'beta',
'alpha',
'gamma',
])
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('button', { pressed: true })).toHaveFocus()
await user.keyboard('{Enter}')
expect(onChange).toHaveBeenCalledExactlyOnceWith([
initialVariables[1],
initialVariables[0],
initialVariables[2],
])
expect(screen.getAllByRole('button', { pressed: false })[1]).toHaveFocus()
})
it('cancels a variable reorder and leaves arrow keys inside name inputs available for editing', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<SortableVariables onChange={onChange} />)
await user.tab()
await user.tab()
expect(screen.getAllByRole('textbox')[0]).toHaveFocus()
await user.keyboard('{ArrowDown}')
expect(onChange).not.toHaveBeenCalled()
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab({ shift: true })
expect(handle).toHaveFocus()
await user.keyboard(' {ArrowDown}{Escape}')
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
'alpha',
'beta',
'gamma',
])
expect(onChange).not.toHaveBeenCalled()
expect(handle).toHaveFocus()
})
it('does not expose variable sorting in readonly mode', () => {
render(<SortableVariables onChange={vi.fn()} readonly />)
expect(screen.queryByRole('button', { pressed: false })).not.toBeInTheDocument()
})
it('cancels a preview before deleting the variable whose remove button was activated', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<SortableVariables onChange={onChange} />)
const handle = screen.getAllByRole('button', { pressed: false })[0]!
await user.tab()
expect(handle).toHaveFocus()
await user.keyboard('{Enter}{ArrowDown}')
await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!)
expect(onChange).toHaveBeenCalledExactlyOnceWith([initialVariables[1], initialVariables[2]])
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
'beta',
'gamma',
])
})
@@ -1,77 +1,49 @@
import type { ReactElement } from 'react'
import type { EmailConfig, FormInputItem } from '../../../types'
import type { ComponentProps } from 'react'
import type { DeliveryMethod, EmailConfig, FormInputItem, HumanInputNodeType } from '../../../types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InputVarType } from '@/app/components/workflow/types'
import { createAccountProfileQueryWrapper } from '@/test/console/account-profile'
import { render as renderWithConsoleState } from '@/test/console/render'
import { useState } from 'react'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import {
createEdge,
createNode,
createStartNode,
} from '@/app/components/workflow/__tests__/fixtures'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import { renderWorkflowEditor } from '@/test/workflow-editor'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodItem from '../method-item'
type EmailConfigureModalProps = {
open: boolean
config?: EmailConfig
onOpenChange: (open: boolean) => void
onConfirm: (data: EmailConfig) => void
}
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const { withConsoleResponses } = await import('@/test/console/service-responses')
return {
...actual,
consoleQuery: withConsoleResponses(actual.consoleQuery, {
'workspaces.current.members.get': () => ({ accounts: [] }),
}),
}
})
type TestEmailSenderProps = {
open: boolean
onOpenChange: (open: boolean) => void
jumpToEmailConfigModal: () => void
}
const mockConsoleState = vi.hoisted(() => ({
userProfile: {
email: 'owner@example.com',
},
}))
const render = (ui: ReactElement) =>
renderWithConsoleState(ui, {
wrapper: createAccountProfileQueryWrapper(mockConsoleState.userProfile),
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
const { createInstance } = await vi.importActual<typeof import('i18next')>('i18next')
const { createReactI18nextMock } = await import('@/test/i18n-mock')
const { default: translations } = await import('@/i18n/locales/en-US/workflow-human-input.json')
const i18n = createInstance()
await i18n.init({
lng: 'en-US',
fallbackLng: false,
keySeparator: false,
resources: { 'en-US': { workflowHumanInput: translations } },
})
vi.mock('../email-configure-modal', () => ({
default: (props: EmailConfigureModalProps) => {
return props.open ? (
<div data-testid="email-configure-modal">
<button
type="button"
onClick={() =>
props.onConfirm({
recipients: { whole_workspace: false, items: [] },
subject: 'Configured subject',
body: '{{#url#}}',
debug_mode: false,
})
}
>
confirm-email-config
</button>
<button type="button" onClick={() => props.onOpenChange(false)}>
close-email-config
</button>
</div>
) : null
},
}))
vi.mock('../test-email-sender', () => ({
default: (props: TestEmailSenderProps) => {
return props.open ? (
<div data-testid="test-email-sender">
<button type="button" onClick={props.jumpToEmailConfigModal}>
jump-to-config
</button>
<button type="button" onClick={() => props.onOpenChange(false)}>
close-test-sender
</button>
</div>
) : null
},
}))
return {
...actual,
...createReactI18nextMock(),
Trans: (props: ComponentProps<typeof actual.Trans>) => <actual.Trans {...props} i18n={i18n} />,
}
})
const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
recipients: {
@@ -96,177 +68,201 @@ const formInputs: FormInputItem[] = [
},
]
const availableNodes = [
{
id: 'start',
data: {
title: 'Start',
type: 'start',
},
},
] as unknown as Node[]
const nodesOutputVars = [
const nodesOutputVars: NodeOutPutVar[] = [
{
nodeId: 'start',
title: 'Start',
vars: [],
vars: [{ variable: 'name', type: VarType.string }],
},
] as NodeOutPutVar[]
]
function DeliveryMethodHarness({
availableNodes,
initialMethod,
readonly = false,
}: {
availableNodes: Node[]
initialMethod: DeliveryMethod
readonly?: boolean
}) {
const [method, setMethod] = useState<DeliveryMethod | null>(initialMethod)
return (
<>
{method && (
<DeliveryMethodItem
nodeId="human-input"
method={method}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
formContent="Hello {{#url#}}"
formInputs={formInputs}
onChange={setMethod}
onDelete={(type) => setMethod((current) => (current?.type === type ? null : current))}
readonly={readonly}
/>
)}
<output aria-label="Saved delivery method">
{method ? JSON.stringify(method) : 'removed'}
</output>
</>
)
}
const renderMethod = (initialMethod: DeliveryMethod, readonly = false) => {
const start = createStartNode({
id: 'start',
data: {
variables: [
{ variable: 'name', label: 'Name', type: InputVarType.textInput, required: false },
],
},
})
const humanInputData: HumanInputNodeType = {
title: 'Human input',
desc: '',
type: BlockEnum.HumanInput,
delivery_methods: [initialMethod],
form_content: 'Hello {{#url#}}',
inputs: formInputs,
user_actions: [],
timeout: 3,
timeout_unit: 'day',
}
const humanInput = createNode({ id: 'human-input', data: humanInputData })
return renderWorkflowEditor(
<DeliveryMethodHarness
availableNodes={[start]}
initialMethod={initialMethod}
readonly={readonly}
/>,
{
nodes: [start, humanInput],
edges: [
createEdge({
source: 'start',
target: 'human-input',
data: { sourceType: BlockEnum.Start, targetType: BlockEnum.HumanInput },
}),
],
},
)
}
const savedMethod = () => screen.getByRole('status', { name: 'Saved delivery method' })
describe('human-input/delivery-method/method-item', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should toggle and delete a webapp delivery method', async () => {
it('toggles and deletes a webapp delivery method through controlled owner state', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const handleDelete = vi.fn()
renderMethod({ id: 'webapp-1', type: DeliveryMethodType.WebApp, enabled: true })
render(
<DeliveryMethodItem
nodeId="node-1"
method={{
id: 'webapp-1',
type: DeliveryMethodType.WebApp,
enabled: true,
}}
onChange={handleChange}
onDelete={handleDelete}
/>,
const enabled = screen.getByRole('switch')
expect(enabled).toBeChecked()
await user.click(enabled)
expect(enabled).not.toBeChecked()
expect(savedMethod()).toHaveTextContent(
JSON.stringify({ id: 'webapp-1', type: DeliveryMethodType.WebApp, enabled: false }),
)
await user.click(screen.getByRole('switch'))
expect(handleChange).toHaveBeenCalledWith({
id: 'webapp-1',
type: DeliveryMethodType.WebApp,
enabled: false,
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(savedMethod()).toHaveTextContent('removed')
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('uses the real test sender and saves subject and body edits from the real configuration form', async () => {
const user = userEvent.setup()
// The jump button exists only for non-debug email with specific recipients; a debug fixture
// cannot reach this production branch.
renderMethod({
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
config: createEmailConfig(),
})
const deleteButton = screen.getByRole('button', { name: 'common.operation.remove' })
await user.click(deleteButton)
expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.WebApp)
})
it('should open configure and test flows for an email method with config', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const handleDelete = vi.fn()
render(
<DeliveryMethodItem
nodeId="node-1"
method={{
id: 'email-1',
type: DeliveryMethodType.Email,
enabled: true,
config: createEmailConfig({ debug_mode: true }),
}}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
formContent="Hello {{#url#}}"
formInputs={formInputs}
onChange={handleChange}
onDelete={handleDelete}
/>,
)
expect(screen.getByText('DEBUG'))!.toBeInTheDocument()
const testButton = screen.getByRole('button', { name: /emailSender.testSendTipInDebugMode/ })
const testButton = screen.getByRole('button', { name: /emailSender.testSendTip/ })
await user.click(testButton)
let sender = screen.getByRole('dialog', { name: /emailSender.title/ })
await user.click(within(sender).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(testButton)
expect(screen.getByTestId('test-email-sender'))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-test-sender' }))
expect(screen.queryByTestId('test-email-sender')).not.toBeInTheDocument()
await user.click(testButton)
await user.click(screen.getByRole('button', { name: 'jump-to-config' }))
expect(screen.queryByTestId('test-email-sender')).not.toBeInTheDocument()
expect(screen.getByTestId('email-configure-modal'))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-email-config' }))
expect(screen.queryByTestId('email-configure-modal')).not.toBeInTheDocument()
sender = screen.getByRole('dialog', { name: /emailSender.title/ })
await user.click(within(sender).getByRole('button', { name: 'enable Debug Mode' }))
const configure = await screen.findByRole('dialog', { name: /emailConfigure.title/ })
expect(screen.queryByRole('dialog', { name: /emailSender.title/ })).not.toBeInTheDocument()
await user.click(within(configure).getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'workflow.common.configure' }))
expect(screen.getByTestId('email-configure-modal'))!.toBeInTheDocument()
const subject = screen.getByRole('textbox', { name: /emailConfigure.subject$/ })
await user.clear(subject)
await user.type(subject, 'Configured subject')
const body = screen.getByRole('textbox', { name: /emailConfigure.body$/ })
await replacePromptEditorText(body, 'Configured body {{#url#}}')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await user.click(screen.getByRole('button', { name: 'confirm-email-config' }))
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(savedMethod()).toHaveTextContent(
JSON.stringify({
id: 'email-1',
type: DeliveryMethodType.Email,
config: expect.objectContaining({
enabled: true,
config: createEmailConfig({
subject: 'Configured subject',
body: 'Configured body {{#url#}}',
}),
}),
)
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.Email)
expect(savedMethod()).toHaveTextContent('removed')
})
it('should open email config from the unconfigured button and from the test sender jump action', async () => {
it('opens and dismisses the real configuration form for an unconfigured email method', async () => {
const user = userEvent.setup()
render(
<DeliveryMethodItem
nodeId="node-1"
method={{
id: 'email-2',
type: DeliveryMethodType.Email,
enabled: false,
config: undefined,
}}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
formInputs={formInputs}
onChange={vi.fn()}
onDelete={vi.fn()}
/>,
)
const method: DeliveryMethod = {
id: 'email-2',
type: DeliveryMethodType.Email,
enabled: false,
config: undefined,
}
renderMethod(method)
await user.click(
screen.getByRole('button', {
name: /workflowHumanInput.nodes.humanInput.deliveryMethod.notConfigured/i,
}),
)
expect(screen.getByTestId('email-configure-modal'))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'close-email-config' }))
expect(screen.queryByTestId('email-configure-modal')).not.toBeInTheDocument()
const configure = screen.getByRole('dialog', { name: /emailConfigure.title/ })
expect(within(configure).getByRole('textbox', { name: /emailConfigure.subject$/ })).toHaveValue(
'',
)
await user.click(within(configure).getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(savedMethod()).toHaveTextContent(JSON.stringify(method))
})
it('should keep actions disabled in readonly mode', async () => {
it('keeps actions disabled in readonly mode without changing owner state', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const method: DeliveryMethod = {
id: 'email-3',
type: DeliveryMethodType.Email,
enabled: true,
config: createEmailConfig(),
}
renderMethod(method, true)
render(
<DeliveryMethodItem
nodeId="node-1"
method={{
id: 'email-3',
type: DeliveryMethodType.Email,
enabled: true,
config: createEmailConfig(),
}}
onChange={handleChange}
onDelete={vi.fn()}
readonly
/>,
)
await user.click(screen.getByRole('switch'))
expect(handleChange).not.toHaveBeenCalled()
const enabled = screen.getByRole('switch')
expect(enabled).toHaveAttribute('aria-disabled', 'true')
await user.click(enabled)
expect(enabled).toBeChecked()
expect(
screen.queryByRole('button', { name: 'common.operation.remove' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'workflow.common.configure' }),
).not.toBeInTheDocument()
expect(screen.queryByTestId('email-configure-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('test-email-sender')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(savedMethod()).toHaveTextContent(JSON.stringify(method))
})
})
@@ -1,39 +1,24 @@
import type { LLMNodeType } from '../../types'
import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { replacePromptEditorText } from '@/app/components/base/prompt-editor/plugins/__tests__/test-helpers'
import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
import { BlockEnum } from '@/app/components/workflow/types'
import { renderWorkflowEditor } from '@/test/workflow-editor'
import PanelMemorySection from '../panel-memory-section'
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({
value,
onChange,
readOnly,
}: {
value: string
onChange: (value: string) => void
readOnly?: boolean
}) => (
<textarea
aria-label="Memory prompt"
value={value}
readOnly={readOnly}
onChange={(event) => onChange(event.target.value)}
/>
),
}))
const createInputs = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
title: 'LLM',
desc: '',
type: 'llm' as LLMNodeType['type'],
type: BlockEnum.LLM,
model: {
provider: 'openai',
name: 'gpt-4o',
mode: 'chat' as const,
mode: 'chat',
completion_params: {},
} as LLMNodeType['model'],
},
prompt_template: [],
context: {
enabled: false,
@@ -52,60 +37,110 @@ const createInputs = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
...overrides,
})
const baseProps = {
readOnly: false,
isChatflow: true,
isChatModel: true,
isCompletionModel: false,
inputs: createInputs(),
hasSetBlockStatus: {
history: false,
query: true,
context: false,
},
availableVars: [] as NodeOutPutVar[],
availableNodesWithParent: [] as Node[],
handleSyeQueryChange: vi.fn(),
handleMemoryChange: vi.fn(),
type HarnessProps = {
availableNodesWithParent: Node[]
availableVars: NodeOutPutVar[]
initialMemory?: Memory
isChatflow?: boolean
readOnly?: boolean
resourceKind?: 'app' | 'snippet'
}
function MemoryHarness({
availableNodesWithParent,
availableVars,
initialMemory,
isChatflow = true,
readOnly = false,
resourceKind = 'app',
}: HarnessProps) {
const [inputs, setInputs] = useState(() => createInputs({ memory: initialMemory }))
return (
<>
<PanelMemorySection
readOnly={readOnly}
isChatflow={isChatflow}
isChatModel
isCompletionModel={false}
inputs={inputs}
hasSetBlockStatus={{ history: false, query: true, context: false }}
availableVars={availableVars}
availableNodesWithParent={availableNodesWithParent}
resourceKind={resourceKind}
handleMemoryChange={(memory) => setInputs((current) => ({ ...current, memory }))}
handleSyeQueryChange={(query) =>
setInputs((current) => ({
...current,
memory: { ...current.memory!, query_prompt_template: query },
}))
}
/>
<output aria-label="Saved memory">
{inputs.memory ? JSON.stringify(inputs.memory) : 'unset'}
</output>
</>
)
}
const renderMemory = (props: Omit<HarnessProps, 'availableNodesWithParent' | 'availableVars'>) => {
const start = createStartNode({ id: 'start' })
const llm = createNode({ id: 'llm', data: createInputs({ memory: props.initialMemory }) })
return renderWorkflowEditor(
<MemoryHarness {...props} availableNodesWithParent={[start]} availableVars={[]} />,
{
nodes: [start, llm],
resource:
props.resourceKind === 'snippet'
? { kind: 'snippet', snippetId: 'snippet-1' }
: { kind: 'app', appId: 'app-1', mode: 'advanced-chat' },
},
)
}
const savedMemory = () => screen.getByRole('status', { name: 'Saved memory' })
it.each([
{ resourceKind: 'app' as const, expected: '{{#sys.query#}}\n\n{{#sys.files#}}' },
{ resourceKind: 'snippet' as const, expected: '' },
])(
'enables memory with the correct $resourceKind prompt and saves edits',
'enables memory with the correct $resourceKind prompt and saves real editor edits',
async ({ resourceKind, expected }) => {
const user = userEvent.setup()
const onChange = vi.fn()
function Harness() {
const [memory, setMemory] = useState<Memory>()
return (
<PanelMemorySection
{...baseProps}
resourceKind={resourceKind}
inputs={createInputs({ memory })}
handleMemoryChange={(next) => {
setMemory(next)
onChange(next)
}}
handleSyeQueryChange={(query) =>
setMemory((previous) => ({ ...previous!, query_prompt_template: query }))
}
/>
)
}
render(<Harness />)
renderMemory({ resourceKind })
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledExactlyOnceWith({
window: { enabled: false, size: 50 },
query_prompt_template: expected,
})
expect(screen.getByRole('textbox', { name: 'Memory prompt' })).toHaveValue(expected)
const prompt = screen.getByRole('textbox', { name: 'Memory prompt' })
await user.clear(prompt)
expect(prompt).toHaveValue('')
await user.type(prompt, 'Custom prompt')
expect(screen.getByRole('textbox', { name: 'Memory prompt' })).toHaveValue('Custom prompt')
expect(savedMemory()).toHaveTextContent(
JSON.stringify({
window: { enabled: false, size: 50 },
query_prompt_template: expected,
}),
)
// This composition does not connect the visible "user" heading to the editor. The Lexical
// contenteditable is the sole editable textbox; MemoryConfig also exposes a disabled textbox.
const editableTextboxes = screen
.getAllByRole('textbox')
.filter((element) => element.getAttribute('contenteditable') === 'true')
expect(editableTextboxes).toHaveLength(1)
const prompt = editableTextboxes[0]!
await replacePromptEditorText(prompt, '')
await waitFor(() =>
expect(savedMemory()).toHaveTextContent(
JSON.stringify({
window: { enabled: false, size: 50 },
query_prompt_template: '',
}),
),
)
await replacePromptEditorText(prompt, 'Custom prompt')
await waitFor(() =>
expect(savedMemory()).toHaveTextContent(
JSON.stringify({
window: { enabled: false, size: 50 },
query_prompt_template: 'Custom prompt',
}),
),
)
expect(prompt).toHaveTextContent('Custom prompt')
if (resourceKind === 'app')
expect(screen.getByText('workflow.nodes.llm.sysQueryInUser')).toBeVisible()
else expect(screen.queryByText('workflow.nodes.llm.sysQueryInUser')).not.toBeInTheDocument()
@@ -114,24 +149,21 @@ it.each([
it('prevents memory activation when read-only', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<PanelMemorySection
{...baseProps}
readOnly
inputs={createInputs({ memory: undefined })}
handleMemoryChange={onChange}
/>,
)
await user.click(screen.getByRole('switch'))
expect(onChange).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox', { name: 'Memory prompt' })).not.toBeInTheDocument()
renderMemory({ readOnly: true })
const memorySwitch = screen.getByRole('switch')
expect(memorySwitch).toHaveAttribute('aria-disabled', 'true')
await user.click(memorySwitch)
expect(savedMemory()).toHaveTextContent('unset')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('hides memory settings outside chatflow', () => {
render(
<PanelMemorySection {...baseProps} isChatflow={false} isChatModel={false} isCompletionModel />,
)
const memory: Memory = {
window: { enabled: false, size: 10 },
query_prompt_template: 'Existing prompt',
}
renderMemory({ initialMemory: memory, isChatflow: false })
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(savedMemory()).toHaveTextContent(JSON.stringify(memory))
})
@@ -1,16 +1,14 @@
import type { InputVar } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { InputVarType } from '@/app/components/workflow/types'
import { renderWorkflowEditor } from '@/test/workflow-editor'
import VarItem from '../var-item'
vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) =>
isShow ? (
<div role="dialog">
edit-variable<button onClick={onClose}>Close editor</button>
</div>
) : null,
vi.mock('@/next/navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/next/navigation')>()),
useParams: () => ({ appId: 'app-1' }),
}))
const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
@@ -21,21 +19,57 @@ const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
...overrides,
})
function VarItemHarness() {
const [payload, setPayload] = useState<InputVar | null>(() => createPayload())
return (
<>
{payload && (
<VarItem
readonly={false}
payload={payload}
onChange={(next) => {
setPayload(next)
return true
}}
onRemove={() => setPayload(null)}
/>
)}
<output aria-label="Saved start variable">
{payload ? JSON.stringify(payload) : 'removed'}
</output>
</>
)
}
describe('StartVarItem', () => {
it('opens the editor and removes the variable from its hover actions', async () => {
it('opens and dismisses the prepopulated editor, then removes the variable from owner state', async () => {
const user = userEvent.setup()
const handleRemove = vi.fn()
render(<VarItem readonly={false} payload={createPayload()} onRemove={handleRemove} />)
renderWorkflowEditor(<VarItemHarness />)
await user.hover(screen.getByText('query'))
const edit = screen.getByRole('button', { name: 'common.operation.edit' })
await user.click(edit)
expect(
screen.getByRole('dialog', { name: 'appDebug.variableConfig.editModalTitle' }),
).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'appDebug.variableConfig.varName' })).toHaveValue(
'query',
)
expect(screen.getByRole('textbox', { name: 'appDebug.variableConfig.labelName' })).toHaveValue(
'Query',
)
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
expect(screen.getByRole('dialog')).toHaveTextContent('edit-variable')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByRole('status', { name: 'Saved start variable' })).toHaveTextContent(
JSON.stringify(createPayload()),
)
await user.click(screen.getByRole('button', { name: 'Close editor' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.hover(screen.getByText('query'))
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(handleRemove).toHaveBeenCalledTimes(1)
expect(screen.getByRole('status', { name: 'Saved start variable' })).toHaveTextContent(
'removed',
)
expect(screen.queryByText('query')).not.toBeInTheDocument()
})
})