test(workflow): cover plugin action configuration (#711)

This commit is contained in:
InfinityPacer
2026-08-24 21:46:51 +08:00
committed by GitHub
parent 3a45d7e9de
commit cf61a42749
2 changed files with 195 additions and 0 deletions
@@ -0,0 +1,188 @@
import InvokePluginAction from '@/components/workflow/InvokePluginAction.vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
vi.mock('@vue-flow/core', async () => {
const { defineComponent: defineVueComponent, h: createElement } = await import('vue')
return {
Handle: defineVueComponent({ setup: () => () => createElement('span') }),
Position: { Left: 'left', Right: 'right' },
}
})
type SelectItem = { title: string; value: string }
const SelectStub = defineComponent({
name: 'VSelect',
props: {
items: { type: Array as PropType<SelectItem[]>, default: () => [] },
label: { type: String, default: '' },
modelValue: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup:
(props, { emit }) =>
() =>
h('label', [
h('span', props.label),
h(
'select',
{
'aria-label': props.label,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value),
value: props.modelValue,
},
props.items.map(item => h('option', { value: item.value }, item.title)),
),
]),
})
const TextareaStub = defineComponent({
name: 'VTextarea',
props: {
label: { type: String, default: '' },
modelValue: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup:
(props, { emit }) =>
() =>
h('label', [
h('span', props.label),
h('textarea', {
'aria-label': props.label,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
value: props.modelValue,
}),
]),
})
const BoxStub = defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
})
function createData(overrides: Record<string, unknown> = {}) {
return {
action_id: 'refresh',
action_params: { force: true },
plugin_id: 'plugin-a',
...overrides,
}
}
async function renderAction(data = createData()) {
return renderWithProviders(InvokePluginAction, {
props: { data, id: 'action-1' },
global: {
stubs: {
VAvatar: BoxStub,
VCard: BoxStub,
VCardItem: BoxStub,
VCardSubtitle: BoxStub,
VCardText: BoxStub,
VCardTitle: BoxStub,
VCol: BoxStub,
VDivider: true,
VIcon: true,
VRow: BoxStub,
VSelect: SelectStub,
VTextarea: TextareaStub,
},
},
})
}
describe('InvokePluginAction', () => {
beforeEach(() => {
mocks.apiGet.mockReset().mockResolvedValue([
{
actions: [
{ id: 'refresh', name: '刷新缓存' },
{ id: 'sync', name: '同步数据' },
],
plugin_id: 'plugin-a',
plugin_name: '插件 A',
},
{
actions: [{ id: 'cleanup', name: '清理数据' }],
plugin_id: 'plugin-b',
plugin_name: '插件 B',
},
])
})
it('maps the public plugin action id contract to the selected plugin options', async () => {
const data = createData()
await renderAction(data)
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('workflow/plugin/actions'))
const pluginSelect = screen.getByLabelText('插件')
const actionSelect = screen.getByLabelText('动作ID')
expect(
within(pluginSelect)
.getAllByRole('option')
.map(option => option.getAttribute('value')),
).toEqual(['plugin-a', 'plugin-b'])
expect(
within(actionSelect)
.getAllByRole('option')
.map(option => option.getAttribute('value')),
).toEqual(['refresh', 'sync'])
await fireEvent.update(pluginSelect, 'plugin-b')
await waitFor(() =>
expect(within(screen.getByLabelText('动作ID')).getByRole('option', { name: '清理数据' })).toHaveValue('cleanup'),
)
await fireEvent.update(actionSelect, 'cleanup')
expect(data).toEqual(expect.objectContaining({ action_id: 'cleanup', plugin_id: 'plugin-b' }))
})
it('round-trips object parameters and preserves invalid JSON for correction', async () => {
const data = createData()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderAction(data)
const params = screen.getByLabelText('动作参数')
expect(params).toHaveValue('{\n "force": true\n}')
await fireEvent.update(params, '{"force":false,"limit":2}')
expect(data.action_params).toEqual({ force: false, limit: 2 })
await fireEvent.update(params, '{invalid')
expect(data.action_params).toBe('{invalid')
expect(consoleError).toHaveBeenCalled()
await fireEvent.update(params, '')
expect(data.action_params).toEqual({})
})
it('keeps existing configuration when plugin actions fail to load', async () => {
const error = new Error('plugin actions unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const data = createData({ action_params: 'raw parameters' })
mocks.apiGet.mockRejectedValueOnce(error)
await renderAction(data)
await waitFor(() => expect(consoleError).toHaveBeenCalledWith(error))
expect(screen.getByLabelText('插件')).toBeEmptyDOMElement()
expect(screen.getByLabelText('动作ID')).toBeEmptyDOMElement()
expect(screen.getByLabelText('动作参数')).toHaveValue('raw parameters')
expect(data).toEqual({ action_id: 'refresh', action_params: 'raw parameters', plugin_id: 'plugin-a' })
})
})
+7
View File
@@ -364,6 +364,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/UserAddEditDialog.vue',
'src/components/dialog/WorkflowAddEditDialog.vue',
'src/components/dialog/WorkflowActionsDialog.vue',
'src/components/workflow/InvokePluginAction.vue',
'src/components/workflow/WorkflowSidebar.vue',
'src/components/dialog/OTPAuthDialog.vue',
'src/components/dialog/PasskeyDialog.vue',
@@ -595,6 +596,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
'src/components/workflow/InvokePluginAction.vue': {
branches: 80,
functions: 90,
lines: 90,
statements: 90,
},
'src/components/workflow/WorkflowSidebar.vue': {
branches: 80,
functions: 85,