mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test(workflow): add helper specs and raise targeted workflow coverage (#33995)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,165 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BodyPayloadValueType, BodyType } from '../../types'
|
||||
import CurlPanel from '../curl-panel'
|
||||
import * as curlParser from '../curl-parser'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
mockNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleNodeSelect: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mockNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('curl-panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('parseCurl', () => {
|
||||
it('should parse method, headers, json body, and query params from a valid curl command', () => {
|
||||
const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2')
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(node).toMatchObject({
|
||||
method: 'post',
|
||||
url: 'https://example.com/users',
|
||||
headers: 'Authorization: Bearer token',
|
||||
params: 'page: 1\nsize: 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an error for invalid curl input', () => {
|
||||
expect(curlParser.parseCurl('fetch https://example.com').error).toContain('Invalid cURL command')
|
||||
})
|
||||
|
||||
it('should parse form data and attach typed content headers', () => {
|
||||
const { node, error } = curlParser.parseCurl('curl --request POST --form "file=@report.txt;type=text/plain" --form "name=openai" https://example.com/upload')
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(node).toMatchObject({
|
||||
method: 'post',
|
||||
url: 'https://example.com/upload',
|
||||
headers: 'Content-Type: text/plain',
|
||||
body: {
|
||||
type: BodyType.formData,
|
||||
data: 'file:@report.txt\nname:openai',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse raw payloads and preserve equals signs in the body value', () => {
|
||||
const { node, error } = curlParser.parseCurl('curl --data-binary "token=abc=123" https://example.com/raw')
|
||||
|
||||
expect(error).toBeNull()
|
||||
expect(node?.body).toEqual({
|
||||
type: BodyType.rawText,
|
||||
data: [{
|
||||
type: BodyPayloadValueType.text,
|
||||
value: 'token=abc=123',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['curl -X', 'Missing HTTP method after -X or --request.'],
|
||||
['curl --header', 'Missing header value after -H or --header.'],
|
||||
['curl --data-raw', 'Missing data value after -d, --data, --data-raw, or --data-binary.'],
|
||||
['curl --form', 'Missing form data after -F or --form.'],
|
||||
['curl --json', 'Missing JSON data after --json.'],
|
||||
['curl --form "=broken" https://example.com/upload', 'Invalid form data format.'],
|
||||
['curl -H "Accept: application/json"', 'Missing URL or url not start with http.'],
|
||||
])('should return a descriptive error for %s', (command, expectedError) => {
|
||||
expect(curlParser.parseCurl(command)).toEqual({
|
||||
node: null,
|
||||
error: expectedError,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('component actions', () => {
|
||||
it('should import a parsed curl node and reselect the node after saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onHide = vi.fn()
|
||||
const handleCurlImport = vi.fn()
|
||||
|
||||
render(
|
||||
<CurlPanel
|
||||
nodeId="node-1"
|
||||
isShow
|
||||
onHide={onHide}
|
||||
handleCurlImport={handleCurlImport}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'curl https://example.com')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(handleCurlImport).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'get',
|
||||
url: 'https://example.com',
|
||||
}))
|
||||
expect(mockHandleNodeSelect).toHaveBeenNthCalledWith(1, 'node-1', true)
|
||||
})
|
||||
|
||||
it('should notify the user when the curl command is invalid', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<CurlPanel
|
||||
nodeId="node-1"
|
||||
isShow
|
||||
onHide={vi.fn()}
|
||||
handleCurlImport={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'invalid')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep the panel open when parsing returns no node and no error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onHide = vi.fn()
|
||||
const handleCurlImport = vi.fn()
|
||||
vi.spyOn(curlParser, 'parseCurl').mockReturnValueOnce({
|
||||
node: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(
|
||||
<CurlPanel
|
||||
nodeId="node-1"
|
||||
isShow
|
||||
onHide={onHide}
|
||||
handleCurlImport={handleCurlImport}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
expect(handleCurlImport).not.toHaveBeenCalled()
|
||||
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,7 @@ import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks'
|
||||
import { BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
import { parseCurl } from './curl-parser'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
@ -18,104 +18,6 @@ type Props = {
|
||||
handleCurlImport: (node: HttpNodeType) => void
|
||||
}
|
||||
|
||||
const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
|
||||
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
|
||||
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
|
||||
|
||||
const node: Partial<HttpNodeType> = {
|
||||
title: 'HTTP Request',
|
||||
desc: 'Imported from cURL',
|
||||
method: undefined,
|
||||
url: '',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: '' },
|
||||
}
|
||||
const args = curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
|
||||
let hasData = false
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i].replace(/^['"]|['"]$/g, '')
|
||||
switch (arg) {
|
||||
case '-X':
|
||||
case '--request':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing HTTP method after -X or --request.' }
|
||||
node.method = (args[++i].replace(/^['"]|['"]$/g, '').toLowerCase() as Method) || Method.get
|
||||
hasData = true
|
||||
break
|
||||
case '-H':
|
||||
case '--header':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing header value after -H or --header.' }
|
||||
node.headers += (node.headers ? '\n' : '') + args[++i].replace(/^['"]|['"]$/g, '')
|
||||
break
|
||||
case '-d':
|
||||
case '--data':
|
||||
case '--data-raw':
|
||||
case '--data-binary': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing data value after -d, --data, --data-raw, or --data-binary.' }
|
||||
const bodyPayload = [{
|
||||
type: BodyPayloadValueType.text,
|
||||
value: args[++i].replace(/^['"]|['"]$/g, ''),
|
||||
}]
|
||||
node.body = { type: BodyType.rawText, data: bodyPayload }
|
||||
break
|
||||
}
|
||||
case '-F':
|
||||
case '--form': {
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing form data after -F or --form.' }
|
||||
if (node.body?.type !== BodyType.formData)
|
||||
node.body = { type: BodyType.formData, data: '' }
|
||||
const formData = args[++i].replace(/^['"]|['"]$/g, '')
|
||||
const [key, ...valueParts] = formData.split('=')
|
||||
if (!key)
|
||||
return { node: null, error: 'Invalid form data format.' }
|
||||
let value = valueParts.join('=')
|
||||
|
||||
// To support command like `curl -F "file=@/path/to/file;type=application/zip"`
|
||||
// the `;type=application/zip` should translate to `Content-Type: application/zip`
|
||||
const typeRegex = /^(.+?);type=(.+)$/
|
||||
const typeMatch = typeRegex.exec(value)
|
||||
if (typeMatch) {
|
||||
const [, actualValue, mimeType] = typeMatch
|
||||
value = actualValue
|
||||
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
|
||||
}
|
||||
|
||||
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
|
||||
break
|
||||
}
|
||||
case '--json':
|
||||
if (i + 1 >= args.length)
|
||||
return { node: null, error: 'Missing JSON data after --json.' }
|
||||
node.body = { type: BodyType.json, data: args[++i].replace(/^['"]|['"]$/g, '') }
|
||||
break
|
||||
default:
|
||||
if (arg.startsWith('http') && !node.url)
|
||||
node.url = arg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final method
|
||||
node.method = node.method || (hasData ? Method.post : Method.get)
|
||||
|
||||
if (!node.url)
|
||||
return { node: null, error: 'Missing URL or url not start with http.' }
|
||||
|
||||
// Extract query params from URL
|
||||
const urlParts = node.url?.split('?') || []
|
||||
if (urlParts.length > 1) {
|
||||
node.url = urlParts[0]
|
||||
node.params = urlParts[1].replace(/&/g, '\n').replace(/=/g, ': ')
|
||||
}
|
||||
|
||||
return { node: node as HttpNodeType, error: null }
|
||||
}
|
||||
|
||||
const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
|
||||
const [inputString, setInputString] = useState('')
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal file
171
web/app/components/workflow/nodes/http/components/curl-parser.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { HttpNodeType } from '../types'
|
||||
import { BodyPayloadValueType, BodyType, Method } from '../types'
|
||||
|
||||
const METHOD_ARG_FLAGS = new Set(['-X', '--request'])
|
||||
const HEADER_ARG_FLAGS = new Set(['-H', '--header'])
|
||||
const DATA_ARG_FLAGS = new Set(['-d', '--data', '--data-raw', '--data-binary'])
|
||||
const FORM_ARG_FLAGS = new Set(['-F', '--form'])
|
||||
|
||||
type ParseStepResult = {
|
||||
error: string | null
|
||||
nextIndex: number
|
||||
hasData?: boolean
|
||||
}
|
||||
|
||||
const stripWrappedQuotes = (value: string) => {
|
||||
return value.replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
|
||||
const parseCurlArgs = (curlCommand: string) => {
|
||||
return curlCommand.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) || []
|
||||
}
|
||||
|
||||
const buildDefaultNode = (): Partial<HttpNodeType> => ({
|
||||
title: 'HTTP Request',
|
||||
desc: 'Imported from cURL',
|
||||
method: undefined,
|
||||
url: '',
|
||||
headers: '',
|
||||
params: '',
|
||||
body: { type: BodyType.none, data: '' },
|
||||
})
|
||||
|
||||
const extractUrlParams = (url: string) => {
|
||||
const urlParts = url.split('?')
|
||||
if (urlParts.length <= 1)
|
||||
return { url, params: '' }
|
||||
|
||||
return {
|
||||
url: urlParts[0],
|
||||
params: urlParts[1].replace(/&/g, '\n').replace(/=/g, ': '),
|
||||
}
|
||||
}
|
||||
|
||||
const getNextArg = (args: string[], index: number, error: string): { value: string, error: null } | { value: null, error: string } => {
|
||||
if (index + 1 >= args.length)
|
||||
return { value: null, error }
|
||||
|
||||
return {
|
||||
value: stripWrappedQuotes(args[index + 1]),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const applyMethodArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing HTTP method after -X or --request.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index, hasData: false }
|
||||
|
||||
node.method = (nextArg.value.toLowerCase() as Method) || Method.get
|
||||
return { error: null, nextIndex: index + 1, hasData: true }
|
||||
}
|
||||
|
||||
const applyHeaderArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing header value after -H or --header.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.headers += `${node.headers ? '\n' : ''}${nextArg.value}`
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyDataArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing data value after -d, --data, --data-raw, or --data-binary.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.body = {
|
||||
type: BodyType.rawText,
|
||||
data: [{ type: BodyPayloadValueType.text, value: nextArg.value }],
|
||||
}
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyFormArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing form data after -F or --form.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
if (node.body?.type !== BodyType.formData)
|
||||
node.body = { type: BodyType.formData, data: '' }
|
||||
|
||||
const [key, ...valueParts] = nextArg.value.split('=')
|
||||
if (!key)
|
||||
return { error: 'Invalid form data format.', nextIndex: index }
|
||||
|
||||
let value = valueParts.join('=')
|
||||
const typeMatch = /^(.+?);type=(.+)$/.exec(value)
|
||||
if (typeMatch) {
|
||||
const [, actualValue, mimeType] = typeMatch
|
||||
value = actualValue
|
||||
node.headers += `${node.headers ? '\n' : ''}Content-Type: ${mimeType}`
|
||||
}
|
||||
|
||||
node.body.data += `${node.body.data ? '\n' : ''}${key}:${value}`
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const applyJsonArg = (node: Partial<HttpNodeType>, args: string[], index: number): ParseStepResult => {
|
||||
const nextArg = getNextArg(args, index, 'Missing JSON data after --json.')
|
||||
if (nextArg.error || nextArg.value === null)
|
||||
return { error: nextArg.error, nextIndex: index }
|
||||
|
||||
node.body = { type: BodyType.json, data: nextArg.value }
|
||||
return { error: null, nextIndex: index + 1 }
|
||||
}
|
||||
|
||||
const handleCurlArg = (
|
||||
arg: string,
|
||||
node: Partial<HttpNodeType>,
|
||||
args: string[],
|
||||
index: number,
|
||||
): ParseStepResult => {
|
||||
if (METHOD_ARG_FLAGS.has(arg))
|
||||
return applyMethodArg(node, args, index)
|
||||
|
||||
if (HEADER_ARG_FLAGS.has(arg))
|
||||
return applyHeaderArg(node, args, index)
|
||||
|
||||
if (DATA_ARG_FLAGS.has(arg))
|
||||
return applyDataArg(node, args, index)
|
||||
|
||||
if (FORM_ARG_FLAGS.has(arg))
|
||||
return applyFormArg(node, args, index)
|
||||
|
||||
if (arg === '--json')
|
||||
return applyJsonArg(node, args, index)
|
||||
|
||||
if (arg.startsWith('http') && !node.url)
|
||||
node.url = arg
|
||||
|
||||
return { error: null, nextIndex: index, hasData: false }
|
||||
}
|
||||
|
||||
export const parseCurl = (curlCommand: string): { node: HttpNodeType | null, error: string | null } => {
|
||||
if (!curlCommand.trim().toLowerCase().startsWith('curl'))
|
||||
return { node: null, error: 'Invalid cURL command. Command must start with "curl".' }
|
||||
|
||||
const node = buildDefaultNode()
|
||||
const args = parseCurlArgs(curlCommand)
|
||||
let hasData = false
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const result = handleCurlArg(stripWrappedQuotes(args[i]), node, args, i)
|
||||
if (result.error)
|
||||
return { node: null, error: result.error }
|
||||
|
||||
hasData ||= Boolean(result.hasData)
|
||||
i = result.nextIndex
|
||||
}
|
||||
|
||||
node.method = node.method || (hasData ? Method.post : Method.get)
|
||||
|
||||
if (!node.url)
|
||||
return { node: null, error: 'Missing URL or url not start with http.' }
|
||||
|
||||
const parsedUrl = extractUrlParams(node.url)
|
||||
node.url = parsedUrl.url
|
||||
node.params = parsedUrl.params
|
||||
|
||||
return { node: node as HttpNodeType, error: null }
|
||||
}
|
||||
Reference in New Issue
Block a user