mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix: 隔离工作流编辑草稿并保留动作契约 (#705)
This commit is contained in:
@@ -9,6 +9,7 @@ import api from '@/api'
|
||||
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言支持
|
||||
@@ -93,25 +94,24 @@ const getEdgeConfigValue = (edge: any, key: string) => {
|
||||
return edge?.[key] ?? edge?.data?.[key] ?? ''
|
||||
}
|
||||
|
||||
// 复制对象并移除不再由前端编辑的高级配置
|
||||
// 复制对象并移除仅用于编辑器展示的契约元数据
|
||||
const omitConfigKeys = (value: any, keys: string[]) => {
|
||||
const result = { ...(value || {}) }
|
||||
keys.forEach(key => delete result[key])
|
||||
return result
|
||||
}
|
||||
|
||||
// 统一流程边数据结构,前端只编辑边条件,汇合和分支策略由执行器默认处理
|
||||
// 统一流程边的条件和画布展示字段,同时保留后端兼容的流程策略字段。
|
||||
const normalizeWorkflowEdge = (edge: any) => {
|
||||
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
|
||||
const edgeClass = String(edge?.class || '')
|
||||
.replace(/\bworkflow-conditional-edge\b/g, '')
|
||||
.trim()
|
||||
const data = omitConfigKeys(edge?.data, ['join_policy', 'branch_policy'])
|
||||
const data = { ...(edge?.data || {}) }
|
||||
data.condition = condition || undefined
|
||||
const edgePayload = omitConfigKeys(edge, ['join_policy', 'branch_policy'])
|
||||
|
||||
return {
|
||||
...edgePayload,
|
||||
...edge,
|
||||
animated: edge?.animated ?? true,
|
||||
type: edge?.type || 'animation',
|
||||
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
|
||||
@@ -126,22 +126,10 @@ const normalizeWorkflowEdges = () => {
|
||||
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
|
||||
}
|
||||
|
||||
// 统一动作节点数据结构,高级运行配置由后端默认值和动作契约接管
|
||||
// 统一动作节点数据结构,保留后端可识别的运行配置,仅移除编辑器契约元数据。
|
||||
const normalizeWorkflowNode = (node: any) => {
|
||||
const hiddenConfigKeys = [
|
||||
'inputs',
|
||||
'outputs',
|
||||
'join_policy',
|
||||
'fail_policy',
|
||||
'branch_policy',
|
||||
'concurrency_key',
|
||||
'timeout',
|
||||
'retry',
|
||||
'contract',
|
||||
'_contract',
|
||||
]
|
||||
const data = omitConfigKeys(node?.data, hiddenConfigKeys)
|
||||
const nodePayload = omitConfigKeys(node, hiddenConfigKeys)
|
||||
const data = omitConfigKeys(node?.data, ['contract', '_contract'])
|
||||
const nodePayload = omitConfigKeys(node, ['contract', '_contract'])
|
||||
|
||||
return {
|
||||
...nodePayload,
|
||||
@@ -318,8 +306,8 @@ const props = defineProps({
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// 站点编辑表单数据
|
||||
const workflowForm = ref<any>(props.workflow || {})
|
||||
// 工作流编辑草稿与传入对象隔离,避免画布、导入和失败保存回写调用方状态。
|
||||
const workflowForm = ref<any>(cloneDeep(props.workflow || {}))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -354,7 +342,8 @@ function handleComponentClick(action: any) {
|
||||
name: action.name,
|
||||
description: action.desc || '',
|
||||
position,
|
||||
data: {},
|
||||
// 节点数据与侧栏动作配置隔离,保持和桌面拖放相同的复制语义。
|
||||
data: cloneDeep(action.data || {}),
|
||||
}
|
||||
|
||||
// 添加节点到画布
|
||||
@@ -420,8 +409,8 @@ function shareWorkflow() {
|
||||
onMounted(() => {
|
||||
loadActionDefinitions()
|
||||
if (props.workflow) {
|
||||
nodes.value = props.workflow.actions ?? []
|
||||
edges.value = props.workflow.flows ?? []
|
||||
nodes.value = cloneDeep(workflowForm.value.actions ?? [])
|
||||
edges.value = cloneDeep(workflowForm.value.flows ?? [])
|
||||
normalizeWorkflowNodes()
|
||||
normalizeWorkflowEdges()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h, type PropType, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type FlowNode = Record<string, unknown>
|
||||
type FlowEdge = Record<string, unknown>
|
||||
type Connection = {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}
|
||||
type ConditionItem = { title: string; value: string }
|
||||
type ActionData = { settings: { enabled: boolean; tags: string[] } }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastWarning: vi.fn(),
|
||||
flowNodes: undefined as Ref<FlowNode[]> | undefined,
|
||||
flowEdges: undefined as Ref<FlowEdge[]> | undefined,
|
||||
onConnect: undefined as ((connection: Connection) => void) | undefined,
|
||||
isValidConnection: undefined as ((connection: Connection) => boolean) | undefined,
|
||||
conditionItems: [] as ConditionItem[],
|
||||
importCode: '',
|
||||
actionData: { settings: { enabled: true, tags: ['source'] } } as ActionData,
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
put: (...args: unknown[]) => mocks.apiPut(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
info: mocks.toastInfo,
|
||||
success: mocks.toastSuccess,
|
||||
warning: mocks.toastWarning,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@vue-flow/core', async () => {
|
||||
const { defineComponent: defineVueComponent, h: createElement, ref } = await import('vue')
|
||||
|
||||
mocks.flowNodes ??= ref<FlowNode[]>([])
|
||||
mocks.flowEdges ??= ref<FlowEdge[]>([])
|
||||
|
||||
const VueFlowStub = defineVueComponent({
|
||||
name: 'VueFlow',
|
||||
props: {
|
||||
nodes: { type: Array as PropType<FlowNode[]>, default: () => [] },
|
||||
edges: { type: Array as PropType<FlowEdge[]>, default: () => [] },
|
||||
isValidConnection: { type: Function as PropType<(connection: Connection) => boolean> },
|
||||
},
|
||||
emits: ['edge-click'],
|
||||
setup(props, { emit }) {
|
||||
mocks.isValidConnection = props.isValidConnection
|
||||
return () =>
|
||||
createElement('div', { 'data-testid': 'vue-flow' }, [
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'select-edge',
|
||||
type: 'button',
|
||||
onClick: () => emit('edge-click', { edge: props.edges[0] }),
|
||||
},
|
||||
'select edge',
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
VueFlow: VueFlowStub,
|
||||
useVueFlow: () => ({
|
||||
addEdges: (newEdges: FlowEdge[] | FlowEdge) =>
|
||||
mocks.flowEdges?.value.push(...(Array.isArray(newEdges) ? newEdges : [newEdges])),
|
||||
addNodes: (newNodes: FlowNode[] | FlowNode) =>
|
||||
mocks.flowNodes?.value.push(...(Array.isArray(newNodes) ? newNodes : [newNodes])),
|
||||
edges: mocks.flowEdges,
|
||||
nodes: mocks.flowNodes,
|
||||
onConnect: (handler: (connection: Connection) => void) => {
|
||||
mocks.onConnect = handler
|
||||
},
|
||||
onNodesInitialized: (handler: () => void) => ({ off: () => undefined, handler }),
|
||||
screenToFlowCoordinate: (point: { x: number; y: number }) => point,
|
||||
updateNode: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vue-flow/minimap', async () => {
|
||||
const { defineComponent: defineVueComponent, h: createElement } = await import('vue')
|
||||
return {
|
||||
MiniMap: defineVueComponent({
|
||||
name: 'MiniMap',
|
||||
setup: () => () => createElement('div', { 'data-testid': 'mini-map' }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@core/utils/workflow', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
default: () => ({
|
||||
isDragOver: ref(false),
|
||||
onDragLeave: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/workflow/WorkflowSidebar.vue', async () => {
|
||||
const { defineComponent: defineVueComponent, h: createElement } = await import('vue')
|
||||
return {
|
||||
default: defineVueComponent({
|
||||
name: 'WorkflowSidebar',
|
||||
emits: ['component-click'],
|
||||
setup:
|
||||
(_, { emit }) =>
|
||||
() =>
|
||||
createElement('div', { 'data-testid': 'workflow-sidebar' }, [
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'add-action',
|
||||
type: 'button',
|
||||
onClick: () =>
|
||||
emit('component-click', {
|
||||
type: 'NewAction',
|
||||
name: '新动作',
|
||||
desc: '测试动作',
|
||||
data: mocks.actionData,
|
||||
}),
|
||||
},
|
||||
'add action',
|
||||
),
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'add-action-without-data',
|
||||
type: 'button',
|
||||
onClick: () => emit('component-click', { type: 'EmptyAction', name: '空动作' }),
|
||||
},
|
||||
'add empty action',
|
||||
),
|
||||
]),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/workflow/DropzoneBackground.vue', async () => {
|
||||
const { defineComponent: defineVueComponent, h: createElement } = await import('vue')
|
||||
return {
|
||||
default: defineVueComponent({
|
||||
name: 'DropzoneBackground',
|
||||
setup: () => () => createElement('div', { 'data-testid': 'dropzone-background' }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/dialog/ImportCodeDialog.vue', async () => {
|
||||
const { defineComponent: defineVueComponent, h: createElement } = await import('vue')
|
||||
return {
|
||||
default: defineVueComponent({
|
||||
name: 'ImportCodeDialog',
|
||||
emits: ['close', 'save', 'update:modelValue'],
|
||||
setup:
|
||||
(_, { emit }) =>
|
||||
() =>
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'import-code-save',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
emit('save', 'workflow', { value: mocks.importCode })
|
||||
emit('close')
|
||||
},
|
||||
},
|
||||
'import',
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
name: 'VSelect',
|
||||
props: {
|
||||
items: { type: Array as PropType<ConditionItem[]>, default: () => [] },
|
||||
modelValue: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup:
|
||||
(props, { emit }) =>
|
||||
() => {
|
||||
mocks.conditionItems = props.items
|
||||
return h(
|
||||
'select',
|
||||
{
|
||||
'data-testid': 'edge-condition-select',
|
||||
value: props.modelValue,
|
||||
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
|
||||
},
|
||||
props.items.map(item => h('option', { key: item.value, value: item.value }, item.title)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DialogStub = defineComponent({
|
||||
name: 'VDialog',
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h('div', { role: 'dialog' }, slots.default?.()),
|
||||
})
|
||||
|
||||
function createNode(id: string, type = 'SourceAction', advanced: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
position: { x: 0, y: 0 },
|
||||
handleBounds: {
|
||||
source: [{ id: 'out' }],
|
||||
target: [{ id: 'in' }],
|
||||
},
|
||||
data: {},
|
||||
...advanced,
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkflow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'workflow-1',
|
||||
name: '工作流契约测试',
|
||||
actions: [createNode('source'), createNode('target', 'TargetAction')],
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target' }],
|
||||
execution_config: { max_workers: 2 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDialog(workflow = createWorkflow(), onSave = vi.fn()) {
|
||||
return renderWithProviders(WorkflowActionsDialog, {
|
||||
props: { workflow, onSave },
|
||||
global: {
|
||||
stubs: {
|
||||
VDialog: DialogStub,
|
||||
VSelect: SelectStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function clickToolbarButton(container: ParentNode, index: number) {
|
||||
const buttons = container.querySelectorAll('.v-toolbar button')
|
||||
const button = buttons[index]
|
||||
if (!button) throw new Error(`Missing toolbar button ${index}`)
|
||||
await fireEvent.click(button)
|
||||
}
|
||||
|
||||
describe('WorkflowActionsDialog data contract', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPut.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastInfo.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.toastWarning.mockReset()
|
||||
mocks.flowNodes!.value = []
|
||||
mocks.flowEdges!.value = []
|
||||
mocks.onConnect = undefined
|
||||
mocks.isValidConnection = undefined
|
||||
mocks.conditionItems = []
|
||||
mocks.importCode = ''
|
||||
mocks.actionData = { settings: { enabled: true, tags: ['source'] } }
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
{
|
||||
type: 'SourceAction',
|
||||
name: '来源动作',
|
||||
contract: {
|
||||
condition_fields: [
|
||||
{ name: 'torrents', label: '资源', kind: 'list' },
|
||||
{ name: 'title', label: '标题', kind: 'scalar' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
mocks.apiPut.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('accepts only known output-to-input connections and rejects invalid endpoints', async () => {
|
||||
const { container } = await renderDialog()
|
||||
await waitFor(() => expect(mocks.isValidConnection).toBeTypeOf('function'))
|
||||
|
||||
const valid = { source: 'source', sourceHandle: 'out', target: 'target', targetHandle: 'in' }
|
||||
expect(mocks.isValidConnection?.(valid)).toBe(true)
|
||||
expect(mocks.isValidConnection?.({ ...valid, source: 'missing' })).toBe(false)
|
||||
expect(mocks.isValidConnection?.({ ...valid, sourceHandle: 'in' })).toBe(false)
|
||||
expect(mocks.isValidConnection?.({ ...valid, targetHandle: 'out' })).toBe(false)
|
||||
expect(mocks.isValidConnection?.({ ...valid, target: 'source', targetHandle: 'in' })).toBe(false)
|
||||
|
||||
mocks.onConnect?.(valid)
|
||||
expect(mocks.flowEdges!.value).toHaveLength(2)
|
||||
expect(mocks.flowEdges!.value[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
source: 'source',
|
||||
sourceHandle: 'out',
|
||||
target: 'target',
|
||||
targetHandle: 'in',
|
||||
type: 'animation',
|
||||
animated: true,
|
||||
}),
|
||||
)
|
||||
expect(container.querySelector('[data-testid="vue-flow"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rejects connections when either node has no declared handle bounds', async () => {
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({
|
||||
actions: [createNode('source'), { id: 'target', type: 'TargetAction', data: {} }],
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(mocks.isValidConnection).toBeTypeOf('function'))
|
||||
|
||||
expect(
|
||||
mocks.isValidConnection?.({ source: 'source', sourceHandle: 'out', target: 'target', targetHandle: 'in' }),
|
||||
).toBe(false)
|
||||
expect(container.querySelector('[data-testid="vue-flow"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns and does not add an invalid connection through the connect callback', async () => {
|
||||
await renderDialog()
|
||||
await waitFor(() => expect(mocks.onConnect).toBeTypeOf('function'))
|
||||
|
||||
mocks.onConnect?.({ source: 'source', sourceHandle: 'in', target: 'target', targetHandle: 'in' })
|
||||
|
||||
expect(mocks.toastWarning).toHaveBeenCalledWith('非法连接:不能连接自身或同类型端口!')
|
||||
expect(mocks.flowEdges!.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('builds list, scalar, and custom condition expressions from the source action contract', async () => {
|
||||
const workflow = createWorkflow({
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', condition: 'outputs.source.legacy == 1' }],
|
||||
})
|
||||
const { container } = await renderDialog(workflow)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() => expect(mocks.conditionItems).toHaveLength(5))
|
||||
|
||||
expect(mocks.conditionItems).toEqual([
|
||||
{ title: '无条件流转', value: '' },
|
||||
{ title: '有资源输出', value: 'outputs.source.torrents.count > 0' },
|
||||
{ title: '没有资源输出', value: 'outputs.source.torrents.count == 0' },
|
||||
{ title: '标题有值', value: 'outputs.source.title != None' },
|
||||
{ title: '自定义条件(保留现有)', value: 'outputs.source.legacy == 1' },
|
||||
])
|
||||
expect(container.querySelector('[data-testid="edge-condition-select"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps an existing condition editable without a contract and closes the panel after deleting the edge', async () => {
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', condition: 'outputs.source.ready == True' }],
|
||||
}),
|
||||
)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() =>
|
||||
expect(mocks.conditionItems).toEqual([
|
||||
{ title: '无条件流转', value: '' },
|
||||
{ title: '自定义条件(保留现有)', value: 'outputs.source.ready == True' },
|
||||
]),
|
||||
)
|
||||
|
||||
const panel = container.querySelector('.workflow-edge-panel')
|
||||
expect(panel).toBeInTheDocument()
|
||||
const actionButtons = panel?.querySelector('.edge-panel-actions')?.querySelectorAll('button')
|
||||
if (!actionButtons) throw new Error('Missing edge action buttons')
|
||||
await fireEvent.click(actionButtons[0])
|
||||
expect(mocks.flowEdges!.value).toHaveLength(0)
|
||||
expect(container.querySelector('.workflow-edge-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the edge panel when the selected edge is removed from the graph', async () => {
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', condition: 'outputs.source.ready == True' }],
|
||||
}),
|
||||
)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() => expect(container.querySelector('.workflow-edge-panel')).toBeInTheDocument())
|
||||
|
||||
mocks.flowEdges!.value = []
|
||||
await waitFor(() => expect(container.querySelector('.workflow-edge-panel')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('closes the edge panel when a graph update leaves the selected edge without a condition source', async () => {
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', condition: 'outputs.source.ready == True' }],
|
||||
}),
|
||||
)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() => expect(container.querySelector('.workflow-edge-panel')).toBeInTheDocument())
|
||||
const select = screen.getByTestId('edge-condition-select')
|
||||
await fireEvent.update(select, '')
|
||||
const saveButton = container.querySelector('.edge-panel-actions')?.querySelectorAll('button')[2]
|
||||
if (!saveButton) throw new Error('Missing edge save button')
|
||||
await fireEvent.click(saveButton)
|
||||
|
||||
mocks.flowNodes!.value[0].type = 'UnknownAction'
|
||||
await waitFor(() => expect(container.querySelector('.workflow-edge-panel')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('falls back to declared output fields when the action contract has no condition_fields', async () => {
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
{
|
||||
type: 'SourceAction',
|
||||
name: '来源动作',
|
||||
contract: { outputs: [{ name: 'ready', label: '就绪', kind: 'scalar' }] },
|
||||
},
|
||||
])
|
||||
await renderDialog()
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() =>
|
||||
expect(mocks.conditionItems).toEqual([
|
||||
{ title: '无条件流转', value: '' },
|
||||
{ title: '就绪有值', value: 'outputs.source.ready != None' },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips malformed condition fields and accepts action contracts without metadata', async () => {
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
{
|
||||
type: 'SourceAction',
|
||||
name: '来源动作',
|
||||
contract: {
|
||||
condition_fields: [null, { name: 'status', label: '', kind: 'scalar' }],
|
||||
},
|
||||
},
|
||||
{ type: 'TargetAction', name: '目标动作' },
|
||||
])
|
||||
await renderDialog()
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() =>
|
||||
expect(mocks.conditionItems).toEqual([
|
||||
{ title: '无条件流转', value: '' },
|
||||
{ title: 'status有值', value: 'outputs.source.status != None' },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('adds a mobile action and copies the normalized workflow without dropping fields', async () => {
|
||||
const writeText = vi.fn()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const { container } = await renderDialog()
|
||||
|
||||
await fireEvent.click(screen.getByTestId('add-action'))
|
||||
expect(mocks.flowNodes!.value).toHaveLength(3)
|
||||
expect(mocks.flowNodes!.value[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'NewAction',
|
||||
name: '新动作',
|
||||
description: '测试动作',
|
||||
data: { settings: { enabled: true, tags: ['source'] } },
|
||||
position: { x: window.innerWidth / 2, y: window.innerHeight / 3 },
|
||||
}),
|
||||
)
|
||||
const firstActionData = mocks.flowNodes!.value[2].data as ActionData
|
||||
firstActionData.settings.enabled = false
|
||||
firstActionData.settings.tags.push('mutated')
|
||||
expect(mocks.actionData).toEqual({ settings: { enabled: true, tags: ['source'] } })
|
||||
|
||||
await fireEvent.click(screen.getByTestId('add-action'))
|
||||
expect(mocks.flowNodes!.value[3].data).toEqual({ settings: { enabled: true, tags: ['source'] } })
|
||||
expect(mocks.flowNodes!.value[3].data).not.toBe(firstActionData)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('add-action-without-data'))
|
||||
expect(mocks.flowNodes!.value[4].data).toEqual({})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已添加组件到画布')
|
||||
|
||||
await clickToolbarButton(container, 2)
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1))
|
||||
expect(JSON.parse(writeText.mock.calls[0][0])).toEqual(
|
||||
expect.objectContaining({
|
||||
actions: expect.any(Array),
|
||||
flows: expect.any(Array),
|
||||
execution_config: { max_workers: 2 },
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('任务流程代码已复制到剪贴板!')
|
||||
})
|
||||
|
||||
it('starts with an empty editor when workflow and action lists are omitted', async () => {
|
||||
const { container } = await renderWithProviders(WorkflowActionsDialog, {
|
||||
props: { workflow: undefined },
|
||||
global: {
|
||||
stubs: {
|
||||
VDialog: DialogStub,
|
||||
VSelect: SelectStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(mocks.flowNodes!.value).toEqual([]))
|
||||
expect(mocks.flowEdges!.value).toEqual([])
|
||||
|
||||
await fireEvent.click(screen.getByTestId('add-action-without-data'))
|
||||
expect(mocks.flowNodes!.value).toHaveLength(1)
|
||||
expect(mocks.flowNodes!.value[0].data).toEqual({})
|
||||
|
||||
await clickToolbarButton(container, 3)
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.apiPut.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ actions: expect.any(Array), flows: expect.any(Array) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an edge without a condition or source contract after action loading fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mocks.apiGet.mockRejectedValue(new Error('actions unavailable'))
|
||||
const { container } = await renderDialog()
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() => expect(mocks.toastInfo).toHaveBeenCalledWith('上一节点没有可用于条件判断的输出'))
|
||||
expect(container.querySelector('.workflow-edge-panel')).not.toBeInTheDocument()
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a non-array action response as an empty contract list', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ unexpected: true })
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', condition: 'outputs.source.ready == True' }],
|
||||
}),
|
||||
)
|
||||
|
||||
await fireEvent.click(screen.getByTestId('select-edge'))
|
||||
await waitFor(() => expect(container.querySelector('.workflow-edge-panel')).toBeInTheDocument())
|
||||
expect(mocks.conditionItems).toEqual([
|
||||
{ title: '无条件流转', value: '' },
|
||||
{ title: '自定义条件(保留现有)', value: 'outputs.source.ready == True' },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves backend action and flow fields in both top-level and data forms when saving', async () => {
|
||||
const action = createNode('source', 'SourceAction', {
|
||||
inputs: ['medias'],
|
||||
outputs: { medias: { kind: 'list' } },
|
||||
join_policy: 'any_success',
|
||||
fail_policy: 'continue',
|
||||
branch_policy: 'exclusive',
|
||||
concurrency_key: 'source-key',
|
||||
timeout: 12,
|
||||
retry: { max_attempts: 3, interval: 2, backoff: 1.5 },
|
||||
data: {
|
||||
inputs: ['legacy.medias'],
|
||||
outputs: [{ name: 'legacy', kind: 'list' }],
|
||||
join_policy: 'all_done',
|
||||
fail_policy: 'ignore',
|
||||
branch_policy: 'parallel',
|
||||
concurrency_key: 'legacy-key',
|
||||
timeout: 20,
|
||||
retry: { max_attempts: 2, interval: 1, backoff: 2 },
|
||||
},
|
||||
})
|
||||
const flow = {
|
||||
id: 'flow-1',
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
condition: 'outputs.source.medias != None',
|
||||
join_policy: 'any_success',
|
||||
branch_policy: 'exclusive',
|
||||
data: {
|
||||
condition: 'outputs.source.legacy != None',
|
||||
join_policy: 'all_done',
|
||||
branch_policy: 'parallel',
|
||||
},
|
||||
}
|
||||
const { container } = await renderDialog(
|
||||
createWorkflow({ actions: [action, createNode('target', 'TargetAction')], flows: [flow] }),
|
||||
)
|
||||
|
||||
await clickToolbarButton(container, 3)
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
|
||||
|
||||
const payload = mocks.apiPut.mock.calls[0][1]
|
||||
expect(payload.actions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
inputs: ['medias'],
|
||||
outputs: { medias: { kind: 'list' } },
|
||||
join_policy: 'any_success',
|
||||
fail_policy: 'continue',
|
||||
branch_policy: 'exclusive',
|
||||
concurrency_key: 'source-key',
|
||||
timeout: 12,
|
||||
retry: { max_attempts: 3, interval: 2, backoff: 1.5 },
|
||||
}),
|
||||
)
|
||||
expect(payload.actions[0].data).toEqual(
|
||||
expect.objectContaining({
|
||||
inputs: ['legacy.medias'],
|
||||
outputs: [{ name: 'legacy', kind: 'list' }],
|
||||
join_policy: 'all_done',
|
||||
fail_policy: 'ignore',
|
||||
branch_policy: 'parallel',
|
||||
concurrency_key: 'legacy-key',
|
||||
timeout: 20,
|
||||
retry: { max_attempts: 2, interval: 1, backoff: 2 },
|
||||
}),
|
||||
)
|
||||
expect(payload.flows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
condition: 'outputs.source.medias != None',
|
||||
join_policy: 'any_success',
|
||||
branch_policy: 'exclusive',
|
||||
}),
|
||||
)
|
||||
expect(payload.flows[0].data).toEqual(
|
||||
expect.objectContaining({
|
||||
condition: 'outputs.source.medias != None',
|
||||
join_policy: 'all_done',
|
||||
branch_policy: 'parallel',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('imports workflow JSON and keeps backend fields in the next save payload', async () => {
|
||||
mocks.importCode = JSON.stringify({
|
||||
actions: [
|
||||
createNode('imported', 'SourceAction', {
|
||||
timeout: 9,
|
||||
retry: { max_attempts: 4 },
|
||||
data: { fail_policy: 'continue', timeout: 11 },
|
||||
}),
|
||||
],
|
||||
flows: [],
|
||||
execution_config: { max_workers: 4 },
|
||||
})
|
||||
const { container } = await renderDialog(createWorkflow({ actions: [], flows: [] }))
|
||||
|
||||
await clickToolbarButton(container, 1)
|
||||
await fireEvent.click(await screen.findByTestId('import-code-save'))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('导入成功!')
|
||||
expect(mocks.flowNodes!.value[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
timeout: 9,
|
||||
retry: { max_attempts: 4 },
|
||||
data: { fail_policy: 'continue', timeout: 11 },
|
||||
}),
|
||||
)
|
||||
|
||||
await clickToolbarButton(container, 3)
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.apiPut.mock.calls[0][1].execution_config).toEqual({ max_workers: 4 })
|
||||
expect(mocks.apiPut.mock.calls[0][1].actions[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
timeout: 9,
|
||||
retry: { max_attempts: 4 },
|
||||
data: { fail_policy: 'continue', timeout: 11 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the caller workflow unchanged when import succeeds and the dialog closes', async () => {
|
||||
const workflow = createWorkflow({
|
||||
actions: [
|
||||
createNode('source', 'SourceAction', {
|
||||
data: { settings: { enabled: true, tags: ['original'] } },
|
||||
}),
|
||||
],
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', data: { branch_policy: 'parallel' } }],
|
||||
execution_config: { max_workers: 2, nested: { enabled: true } },
|
||||
})
|
||||
const beforeImport = structuredClone(workflow)
|
||||
mocks.importCode = JSON.stringify({
|
||||
actions: [createNode('imported', 'SourceAction', { data: { settings: { enabled: false, tags: ['imported'] } } })],
|
||||
flows: [],
|
||||
execution_config: { max_workers: 5, nested: { enabled: false } },
|
||||
})
|
||||
const { container } = await renderDialog(workflow)
|
||||
|
||||
await clickToolbarButton(container, 1)
|
||||
await fireEvent.click(await screen.findByTestId('import-code-save'))
|
||||
|
||||
expect(workflow).toEqual(beforeImport)
|
||||
expect(screen.queryByTestId('import-code-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['HTTP', new Error('http failure')],
|
||||
['业务', { kind: 'business', message: '保存被拒绝' }],
|
||||
])('keeps the caller workflow unchanged after %s save failure', async (_failureKind, failure) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const workflow = createWorkflow({
|
||||
actions: [
|
||||
createNode('source', 'SourceAction', {
|
||||
data: { settings: { enabled: true, tags: ['original'] } },
|
||||
}),
|
||||
],
|
||||
flows: [{ id: 'flow-1', source: 'source', target: 'target', data: { branch_policy: 'parallel' } }],
|
||||
execution_config: { max_workers: 2, nested: { enabled: true } },
|
||||
})
|
||||
const beforeSave = structuredClone(workflow)
|
||||
mocks.apiPut.mockRejectedValueOnce(failure)
|
||||
const { container } = await renderDialog(workflow)
|
||||
|
||||
await clickToolbarButton(container, 3)
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(workflow).toEqual(beforeSave)
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not save or emit after malformed import or failed update', async () => {
|
||||
const onSave = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { container } = await renderDialog(createWorkflow(), onSave)
|
||||
mocks.importCode = '{broken'
|
||||
|
||||
await clickToolbarButton(container, 1)
|
||||
await fireEvent.click(await screen.findByTestId('import-code-save'))
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('导入失败!')
|
||||
expect(mocks.apiPut).not.toHaveBeenCalled()
|
||||
|
||||
mocks.apiPut.mockRejectedValueOnce(new Error('save failed'))
|
||||
await clickToolbarButton(container, 3)
|
||||
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledTimes(1))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -362,6 +362,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/dialog/SiteAddEditDialog.vue',
|
||||
'src/components/dialog/UserAddEditDialog.vue',
|
||||
'src/components/dialog/WorkflowAddEditDialog.vue',
|
||||
'src/components/dialog/WorkflowActionsDialog.vue',
|
||||
'src/components/dialog/OTPAuthDialog.vue',
|
||||
'src/components/dialog/PasskeyDialog.vue',
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||
@@ -564,6 +565,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/WorkflowActionsDialog.vue': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/dialog/OTPAuthDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
|
||||
Reference in New Issue
Block a user