fix(workflow): align share response contracts (#708)

This commit is contained in:
InfinityPacer
2026-08-24 11:26:00 +08:00
committed by GitHub
parent 30b5c6445a
commit 7c02bf7c5c
5 changed files with 541 additions and 16 deletions
@@ -0,0 +1,94 @@
import type { WorkflowShare } from '@/api/types'
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
import { screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
openSharedDialog: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
function createWorkflowShare(overrides: Partial<WorkflowShare> = {}): WorkflowShare {
return {
date: '2026-08-23 12:00:00',
id: '91',
share_comment: '用于验证工作流分享卡片',
share_title: '分享工作流',
share_user: '测试用户',
...overrides,
}
}
async function renderCard(workflow = createWorkflowShare()) {
const events = {
delete: vi.fn(),
update: vi.fn(),
}
const result = await renderWithProviders(WorkflowShareCard, {
props: {
eventTypes: [{ title: '下载完成', value: 'download_complete' }],
onDelete: events.delete,
onUpdate: events.update,
workflow,
},
})
return { ...result, events, workflow }
}
describe('WorkflowShareCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens the fork dialog with the current workflow and closes on terminal events', async () => {
const user = userEvent.setup()
const { workflow } = await renderCard()
await user.click(screen.getByText('分享工作流'))
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({
eventTypes: [{ title: '下载完成', value: 'download_complete' }],
workflow,
})
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'fork', 'delete'] })
})
it('renders usage and date metadata with an ID-derived gradient', async () => {
const { container } = await renderCard(createWorkflowShare({ count: 1234 }))
expect(screen.getByText('1,234')).toBeInTheDocument()
expect(container.querySelector('.absolute.right-0.bottom-0')).not.toHaveTextContent('')
expect(container.querySelector('.workflow-share-card')?.getAttribute('style')).toContain('linear-gradient')
})
it('uses a fallback gradient and omits optional metadata when identity and usage are absent', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0.25)
const { container } = await renderCard(createWorkflowShare({ count: undefined, date: undefined, id: undefined }))
expect(screen.queryByText('1,234')).not.toBeInTheDocument()
expect(container.querySelector('.workflow-share-card')?.getAttribute('style')).toContain('linear-gradient')
})
it('maps payload-free fork and delete completion to parent refresh events', async () => {
const user = userEvent.setup()
const { events } = await renderCard()
await user.click(screen.getByText('分享工作流'))
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
delete: () => void
fork: () => void
}
dialogEvents.fork()
dialogEvents.delete()
expect(events.update).toHaveBeenCalledOnce()
expect(events.delete).toHaveBeenCalledOnce()
})
})
+4 -4
View File
@@ -78,10 +78,10 @@ async function doFork() {
try {
processing.value = true
// 请求API
const result = await api.post<{ id: string }>('workflow/fork', props.workflow, { feedback: 'silent' })
await api.post('workflow/fork', props.workflow, { feedback: 'silent' })
$toast.success(t('workflow.addSuccess', { name: props.workflow?.share_title }))
// 完成
emit('fork', result.id)
emit('fork')
} catch (error) {
console.error(error)
$toast.error(
@@ -103,7 +103,7 @@ async function doDelete() {
try {
deleting.value = true
// 请求API
const result = await api.delete<{ id: string }>(`workflow/share/${props.workflow?.id}`, {
await api.delete(`workflow/share/${props.workflow?.id}`, {
params: {
share_uid: globalSettings.USER_UNIQUE_ID,
},
@@ -111,7 +111,7 @@ async function doDelete() {
})
$toast.success(t('workflow.cancelSuccess'))
// 完成
emit('delete', result.id)
emit('delete')
} catch (error) {
console.error(error)
$toast.error(t('workflow.cancelFailed', { message: error instanceof Error ? error.message : '' }))
@@ -1,19 +1,260 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import type { WorkflowShare } from '@/api/types'
import ForkWorkflowDialog from '@/components/dialog/ForkWorkflowDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw'
import { apiFailureJson, apiJson } from '@tests/support/msw/response'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const dialogSource = readFileSync('src/components/dialog/ForkWorkflowDialog.vue', 'utf8')
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
describe('ForkWorkflowDialog preview contract', () => {
it('uses the static summary without loading an interactive VueFlow canvas', () => {
expect(dialogSource).toContain('WorkflowSummaryPreview')
expect(dialogSource).not.toContain('@vue-flow/core')
expect(dialogSource).not.toContain('import.meta.glob')
expect(dialogSource).not.toContain('<VueFlow')
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: vi.fn(),
startNProgress: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
const WorkflowSummaryPreviewStub = defineComponent({
name: 'WorkflowSummaryPreview',
props: {
actions: Array,
flows: Array,
},
setup(props) {
return () => h('div', { 'data-testid': 'workflow-preview' }, `${props.actions?.length}:${props.flows?.length}`)
},
})
function createWorkflowShare(overrides: Partial<WorkflowShare> = {}): WorkflowShare {
return {
actions: [{ id: 'action-1' }],
flows: [{ id: 'flow-1' }],
id: '91',
share_comment: '用于验证工作流分享契约',
share_title: '分享工作流',
share_uid: 'owner-id',
share_user: '测试用户',
trigger_type: 'manual',
...overrides,
}
}
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(workflow = createWorkflowShare(), settings: Record<string, unknown> = {}) {
const events = {
close: vi.fn(),
delete: vi.fn(),
fork: vi.fn(),
}
const result = await renderWithProviders(ForkWorkflowDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
stubs: {
WorkflowSummaryPreview: WorkflowSummaryPreviewStub,
},
},
initialState: {
globalSettings: {
data: {
USER_UNIQUE_ID: 'owner-id',
WORKFLOW_SHARE_MANAGE: false,
...settings,
},
},
},
props: {
eventTypes: [{ title: '下载完成', value: 'download_complete' }],
modelValue: true,
onClose: events.close,
onDelete: events.delete,
onFork: events.fork,
workflow,
},
})
it('reserves the close-button safe area on small screens', () => {
expect(dialogSource).toMatch(
/@media screen and \(width <= 600px\)[\s\S]*?\.workflow-share-layout\s*\{[\s\S]*?padding-block-start:\s*2rem/,
return { ...result, events, workflow }
}
describe('ForkWorkflowDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('renders parsed workflow data through the static summary preview', async () => {
await renderDialog(
createWorkflowShare({
actions: JSON.stringify([{ id: 'action-1' }, { id: 'action-2' }]) as unknown as WorkflowShare['actions'],
flows: JSON.stringify([{ id: 'flow-1' }]) as unknown as WorkflowShare['flows'],
}),
)
expect(screen.getByTestId('workflow-preview')).toHaveTextContent('2:1')
expect(document.querySelector('.workflow-share-layout')).toBeInTheDocument()
})
it.each([
['timer', { timer: '0 8 * * *', trigger_type: 'timer' }, '0 8 * * *'],
['legacy timer', { timer: '0 9 * * *', trigger_type: undefined }, '0 9 * * *'],
['known event', { event_type: 'download_complete', trigger_type: 'event' }, '下载完成'],
['unknown event', { event_type: 'custom_event', trigger_type: 'event' }, 'custom_event'],
['manual', { trigger_type: 'manual' }, '手动触发'],
])('renders the %s trigger contract', async (_case, overrides, expected) => {
await renderDialog(createWorkflowShare(overrides))
expect(screen.getByText(expected)).toBeInTheDocument()
})
it('falls back to an empty static preview when serialized workflow data is malformed', async () => {
await renderDialog(
createWorkflowShare({
actions: '{invalid' as unknown as WorkflowShare['actions'],
flows: 'not-json' as unknown as WorkflowShare['flows'],
}),
)
expect(screen.getByTestId('workflow-preview')).toHaveTextContent('0:0')
expect(console.error).toHaveBeenCalledWith('解析工作流数据失败:', expect.any(SyntaxError))
})
it('keeps the fork action pending and completes a Response[None] success without reading data.id', async () => {
const deferred = createDeferred()
const requested = vi.fn(async (payload: unknown) => {
await deferred.promise
return payload
})
server.use(
http.post('/api/v1/workflow/fork', async ({ request }) => {
await requested(await request.json())
return apiJson(null)
}),
)
const user = userEvent.setup()
const { events, workflow } = await renderDialog()
const button = screen.getByRole('button', { name: '复用工作流' })
await user.click(button)
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(button).toBeDisabled()
expect(events.fork).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(events.fork).toHaveBeenCalledOnce())
expect(events.fork).toHaveBeenCalledWith()
expect(requested).toHaveBeenCalledWith(workflow)
expect(mocks.toastSuccess).toHaveBeenCalledWith('复用 分享工作流 成功!')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('reports fork business and HTTP failures without emitting completion', async () => {
const user = userEvent.setup()
const { events, rerender } = await renderDialog(createWorkflowShare({ share_title: '拒绝复用' }))
server.use(http.post('/api/v1/workflow/fork', () => apiFailureJson('工作流无效')))
await user.click(screen.getByRole('button', { name: '复用工作流' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('复用 拒绝复用 失败:工作流无效'))
expect(events.fork).not.toHaveBeenCalled()
mocks.toastError.mockClear()
server.use(http.post('/api/v1/workflow/fork', () => HttpResponse.json({}, { status: 500 })))
await rerender({ workflow: createWorkflowShare({ share_title: '网络失败' }) })
await user.click(screen.getByRole('button', { name: '复用工作流' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('复用 网络失败 失败')))
expect(events.fork).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '复用工作流' })).not.toBeDisabled()
})
it.each([
['the owner', 'owner-id', false, true],
['a share manager', 'other-user', true, true],
['another ordinary user', 'other-user', false, false],
])('shows delete permission for %s', async (_case, shareUid, canManage, visible) => {
await renderDialog(createWorkflowShare({ share_uid: shareUid }), { WORKFLOW_SHARE_MANAGE: canManage })
if (visible) expect(screen.getByRole('button', { name: '取消分享' })).toBeInTheDocument()
else expect(screen.queryByRole('button', { name: '取消分享' })).not.toBeInTheDocument()
})
it('deletes the exact share and completes a Response[None] success without reading data.id', async () => {
const deferred = createDeferred()
const requested = vi.fn(async (url: URL) => {
await deferred.promise
return url
})
server.use(
http.delete('/api/v1/workflow/share/:id', async ({ request }) => {
await requested(new URL(request.url))
return apiJson(null)
}),
)
const user = userEvent.setup()
const { events } = await renderDialog()
const button = screen.getByRole('button', { name: '取消分享' })
await user.click(button)
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(button).toBeDisabled()
expect(events.delete).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(events.delete).toHaveBeenCalledOnce())
expect(events.delete).toHaveBeenCalledWith()
expect(requested.mock.calls[0][0].pathname).toBe('/api/v1/workflow/share/91')
expect(requested.mock.calls[0][0].searchParams.get('share_uid')).toBe('owner-id')
expect(mocks.toastSuccess).toHaveBeenCalledWith('取消分享成功')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('reports delete business and HTTP failures without emitting completion', async () => {
const user = userEvent.setup()
const { events } = await renderDialog()
server.use(http.delete('/api/v1/workflow/share/:id', () => apiFailureJson('没有删除权限')))
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('取消分享失败:没有删除权限'))
expect(events.delete).not.toHaveBeenCalled()
mocks.toastError.mockClear()
server.use(http.delete('/api/v1/workflow/share/:id', () => HttpResponse.json({}, { status: 500 })))
await user.click(screen.getByRole('button', { name: '取消分享' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('取消分享失败')))
expect(events.delete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '取消分享' })).not.toBeDisabled()
})
it('emits close from the dialog close control', async () => {
const user = userEvent.setup()
const { events } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(events.close).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,169 @@
import type { Workflow } from '@/api/types'
import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw'
import { apiFailureJson, apiJson } from '@tests/support/msw/response'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
const DialogCloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
setup(_props, { emit }) {
return () => h('button', { type: 'button', onClick: () => emit('click') }, '关闭')
},
})
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
async function renderDialog(workflow: Workflow | null = { id: '81', name: '待分享工作流' }) {
const close = vi.fn()
const result = await renderWithProviders(WorkflowShareDialog, {
global: {
components: {
VDialogCloseBtn: DialogCloseButtonStub,
},
},
props: {
modelValue: true,
onClose: close,
workflow: workflow ?? undefined,
},
})
return { ...result, close, workflow }
}
async function fillRequiredFields(comment = '覆盖工作流分享契约', shareUser = '测试分享人') {
const user = userEvent.setup()
await user.type(screen.getByLabelText('说明'), comment)
await user.type(screen.getByLabelText('分享用户'), shareUser)
return user
}
describe('WorkflowShareDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it.each([
['description', '', '测试分享人'],
['sharing user', '覆盖工作流分享契约', ''],
])('blocks submission when the %s is missing', async (_case, comment, shareUser) => {
const requested = vi.fn()
server.use(
http.post('/api/v1/workflow/share', () => {
requested()
return apiJson(null)
}),
)
await renderDialog()
const user = userEvent.setup()
if (comment) await user.type(screen.getByLabelText('说明'), comment)
if (shareUser) await user.type(screen.getByLabelText('分享用户'), shareUser)
await user.click(screen.getByRole('button', { name: '确认分享' }))
expect(requested).not.toHaveBeenCalled()
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('submits the exact payload once, stays pending, and closes after success', async () => {
const deferred = createDeferred()
const payloads: unknown[] = []
server.use(
http.post('/api/v1/workflow/share', async ({ request }) => {
payloads.push(await request.json())
await deferred.promise
return apiJson(null)
}),
)
const { close, workflow } = await renderDialog()
const expectedWorkflow = workflow!
const user = await fillRequiredFields('只提交分享字段', '分享者甲')
const button = screen.getByRole('button', { name: '确认分享' })
await user.click(button)
await waitFor(() => expect(payloads).toHaveLength(1))
expect(payloads[0]).toEqual({
id: expectedWorkflow.id,
share_comment: '只提交分享字段',
share_title: expectedWorkflow.name,
share_user: '分享者甲',
})
expect(button).toBeDisabled()
expect(close).not.toHaveBeenCalled()
deferred.resolve()
await waitFor(() => expect(close).toHaveBeenCalledOnce())
expect(mocks.toastSuccess).toHaveBeenCalledWith('待分享工作流 分享成功!')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('keeps the dialog retryable after business and HTTP failures', async () => {
const { close } = await renderDialog()
const user = await fillRequiredFields()
const button = screen.getByRole('button', { name: '确认分享' })
server.use(http.post('/api/v1/workflow/share', () => apiFailureJson('远端拒绝')))
await user.click(button)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('待分享工作流 分享失败:远端拒绝!'))
expect(close).not.toHaveBeenCalled()
expect(button).not.toBeDisabled()
mocks.toastError.mockClear()
server.use(http.post('/api/v1/workflow/share', () => HttpResponse.json({}, { status: 500 })))
await user.click(button)
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('待分享工作流 分享失败')))
expect(close).not.toHaveBeenCalled()
expect(button).not.toBeDisabled()
})
it('tracks a replaced workflow in the submitted share identity', async () => {
const payloads: unknown[] = []
server.use(
http.post('/api/v1/workflow/share', async ({ request }) => {
payloads.push(await request.json())
return apiJson(null)
}),
)
const { rerender } = await renderDialog(null)
expect(screen.getByLabelText('标题')).toHaveValue('')
await rerender({ workflow: { id: '82', name: '替换后的工作流' } })
const user = await fillRequiredFields()
await user.click(screen.getByRole('button', { name: '确认分享' }))
await waitFor(() => expect(payloads).toHaveLength(1))
expect(payloads[0]).toMatchObject({ id: '82', share_title: '替换后的工作流' })
})
it('emits close from the dialog close control', async () => {
const user = userEvent.setup()
const { close } = await renderDialog()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
})
+21
View File
@@ -404,6 +404,9 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/cards/PluginCard.vue',
'src/components/cards/PluginAppCard.vue',
'src/components/cards/WorkflowTaskCard.vue',
'src/components/cards/WorkflowShareCard.vue',
'src/components/dialog/ForkWorkflowDialog.vue',
'src/components/dialog/WorkflowShareDialog.vue',
'src/components/dialog/PluginMarketDetailDialog.vue',
'src/components/dialog/PluginMarketSettingDialog.vue',
'src/components/dialog/PluginVersionHistoryDialog.vue',
@@ -555,6 +558,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
'src/components/cards/WorkflowShareCard.vue': {
branches: 75,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/dialog/ForkWorkflowDialog.vue': {
branches: 75,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/dialog/WorkflowShareDialog.vue': {
branches: 75,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/dialog/UserAddEditDialog.vue': {
branches: 75,
functions: 80,