mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-29 03:56:51 +08:00
fix(workflow): recover list refresh failures (#702)
This commit is contained in:
@@ -4,7 +4,7 @@ import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
apiDelete: vi.fn(),
|
apiDelete: vi.fn(),
|
||||||
@@ -61,6 +61,13 @@ async function renderCard(workflowOverrides: Partial<Workflow> = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAction(container: Element, label: string) {
|
||||||
|
const menuButton = container.querySelector<HTMLButtonElement>('.workflow-task-card__menu')
|
||||||
|
expect(menuButton).not.toBeNull()
|
||||||
|
await fireEvent.click(menuButton!)
|
||||||
|
return screen.getByText(label, { exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
describe('WorkflowTaskCard redesign', () => {
|
describe('WorkflowTaskCard redesign', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.apiDelete.mockReset()
|
mocks.apiDelete.mockReset()
|
||||||
@@ -72,6 +79,11 @@ describe('WorkflowTaskCard redesign', () => {
|
|||||||
mocks.apiPost.mockResolvedValue({ success: true })
|
mocks.apiPost.mockResolvedValue({ success: true })
|
||||||
mocks.confirm.mockResolvedValue(true)
|
mocks.confirm.mockResolvedValue(true)
|
||||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps the generated card icon to the workflow trigger type', async () => {
|
it('maps the generated card icon to the workflow trigger type', async () => {
|
||||||
@@ -190,6 +202,194 @@ describe('WorkflowTaskCard redesign', () => {
|
|||||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the edit dialog and refreshes after its save event', async () => {
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '编辑任务'))
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
|
||||||
|
const editEvents = mocks.openSharedDialog.mock.calls[0][2] as { save?: () => void }
|
||||||
|
editEvents.save?.()
|
||||||
|
expect(emitted().refresh).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the share dialog without assigning edit callbacks', async () => {
|
||||||
|
const { container } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '分享'))
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
|
||||||
|
expect(mocks.openSharedDialog.mock.calls[0][2]).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires deletion confirmation and leaves the card untouched when cancelled', async () => {
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false)
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '删除任务'))
|
||||||
|
|
||||||
|
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: expect.stringContaining('扫描和刮削') }),
|
||||||
|
)
|
||||||
|
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||||
|
expect(emitted().refresh ?? []).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the workflow and refreshes the list after confirmation', async () => {
|
||||||
|
mocks.apiDelete.mockResolvedValueOnce({ success: true })
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '删除任务'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('workflow/workflow-1'))
|
||||||
|
await waitFor(() => expect(emitted().refresh).toHaveLength(1))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('删除任务成功!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refresh after deletion fails at the HTTP boundary', async () => {
|
||||||
|
mocks.apiDelete.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '删除任务'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('workflow/workflow-1'))
|
||||||
|
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||||
|
expect(emitted().refresh).toBeUndefined()
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['启用', 'P', 'workflow/workflow-1/start', '启用任务成功!'],
|
||||||
|
['暂停', 'W', 'workflow/workflow-1/pause', '停用任务成功!'],
|
||||||
|
] as const)('posts the %s action and refreshes after success', async (label, state, endpoint, toast) => {
|
||||||
|
const { emitted } = await renderCard({ state })
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: label }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith(endpoint))
|
||||||
|
await waitFor(() => expect(emitted().refresh).toHaveLength(1))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith(toast)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['启用', 'P', 'workflow/workflow-1/start'],
|
||||||
|
['暂停', 'W', 'workflow/workflow-1/pause'],
|
||||||
|
] as const)('does not refresh or show success after the %s action fails', async (label, state, endpoint) => {
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const { emitted } = await renderCard({ state })
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: label }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith(endpoint))
|
||||||
|
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||||
|
expect(emitted().refresh).toBeUndefined()
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['继续执行', false, 'workflow/workflow-1/run?from_begin=false'],
|
||||||
|
['重新执行', true, 'workflow/workflow-1/run?from_begin=true'],
|
||||||
|
] as const)('%s passes from_begin=%s and keeps the two-refresh sequence', async (label, fromBegin, endpoint) => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const { container, emitted } = await renderCard({ current_action: 'scan' })
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, label))
|
||||||
|
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith(endpoint, { from_begin: fromBegin })
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(emitted().refresh).toHaveLength(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(499)
|
||||||
|
expect(emitted().refresh).toHaveLength(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(emitted().refresh).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a fresh workflow from the run menu when no action is in progress', async () => {
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '立即执行'))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/run?from_begin=true', { from_begin: true }),
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(emitted().refresh?.length).toBeGreaterThan(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the card loading while an enable request is pending and refreshes once it resolves', async () => {
|
||||||
|
let resolvePost!: (value: unknown) => void
|
||||||
|
mocks.apiPost.mockReturnValueOnce(
|
||||||
|
new Promise(resolve => {
|
||||||
|
resolvePost = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { container, emitted } = await renderCard({ state: 'P' })
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '启用' }))
|
||||||
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/start'))
|
||||||
|
const loader = container.querySelector('.v-card__loader .v-progress-linear')
|
||||||
|
expect(loader).toHaveStyle({ height: '2px' })
|
||||||
|
|
||||||
|
resolvePost({ success: true })
|
||||||
|
await waitFor(() => expect(loader).toHaveStyle({ height: '0px' }))
|
||||||
|
expect(emitted().refresh).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the workflow only after confirmation and refreshes on success', async () => {
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '重置任务'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/reset'))
|
||||||
|
await waitFor(() => expect(emitted().refresh).toHaveLength(1))
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('重置任务成功!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refresh after reset confirmation is cancelled or the request fails', async () => {
|
||||||
|
mocks.confirm.mockResolvedValueOnce(false)
|
||||||
|
const cancelled = await renderCard()
|
||||||
|
await fireEvent.click(await openAction(cancelled.container, '重置任务'))
|
||||||
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
|
expect(cancelled.emitted().refresh).toBeUndefined()
|
||||||
|
cancelled.unmount()
|
||||||
|
|
||||||
|
mocks.confirm.mockResolvedValueOnce(true)
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const failed = await renderCard()
|
||||||
|
await fireEvent.click(await openAction(failed.container, '重置任务'))
|
||||||
|
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/reset'))
|
||||||
|
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||||
|
expect(failed.emitted().refresh).toBeUndefined()
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes loading and avoids a success toast after a failed run request', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const { container } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '立即执行'))
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalled()
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
const loader = container.querySelector('.v-card__loader .v-progress-linear')
|
||||||
|
expect(loader).toHaveStyle({ height: '0px' })
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/run?from_begin=true', { from_begin: true })
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the delayed refresh after a failed run request', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||||
|
const { container, emitted } = await renderCard()
|
||||||
|
|
||||||
|
await fireEvent.click(await openAction(container, '立即执行'))
|
||||||
|
await vi.advanceTimersByTimeAsync(499)
|
||||||
|
expect(emitted().refresh).toBeUndefined()
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(emitted().refresh).toHaveLength(1)
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('derives all custom card colors and geometry from global theme tokens', () => {
|
it('derives all custom card colors and geometry from global theme tokens', () => {
|
||||||
const source = readFileSync('src/components/cards/WorkflowTaskCard.vue', 'utf8')
|
const source = readFileSync('src/components/cards/WorkflowTaskCard.vue', 'utf8')
|
||||||
const transparentTheme = readFileSync('src/styles/themes/transparent.scss', 'utf8')
|
const transparentTheme = readFileSync('src/styles/themes/transparent.scss', 'utf8')
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ const { t } = useI18n()
|
|||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 是否加载中
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 最近一次工作流列表加载是否失败
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
|
// 仅允许最新请求提交列表状态,避免并发刷新按完成顺序覆盖较新的结果
|
||||||
|
let latestRequestId = 0
|
||||||
|
|
||||||
// 所有任务
|
// 所有任务
|
||||||
const workflowList = ref<Workflow[]>([])
|
const workflowList = ref<Workflow[]>([])
|
||||||
|
|
||||||
@@ -33,11 +42,22 @@ async function loadEventTypes() {
|
|||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
|
const requestId = ++latestRequestId
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
workflowList.value = await api.get('workflow/')
|
const workflows = await api.get<Workflow[]>('workflow/')
|
||||||
isRefreshed.value = true
|
if (requestId !== latestRequestId) return
|
||||||
|
workflowList.value = workflows
|
||||||
|
loadFailed.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== latestRequestId) return
|
||||||
|
loadFailed.value = true
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestRequestId) {
|
||||||
|
loading.value = false
|
||||||
|
isRefreshed.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +91,31 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="loading && !isRefreshed" class="mt-12" />
|
||||||
|
<NoDataFound
|
||||||
|
v-else-if="loadFailed && workflowList.length === 0"
|
||||||
|
error-code="500"
|
||||||
|
:error-title="t('common.serverConnectionFailed')"
|
||||||
|
>
|
||||||
|
<template #button>
|
||||||
|
<VBtn color="primary" variant="tonal" :loading="loading" @click="fetchData">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</NoDataFound>
|
||||||
|
<VAlert
|
||||||
|
v-else-if="loadFailed"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
:title="t('common.serverConnectionFailed')"
|
||||||
|
class="mx-2 mb-4"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<VBtn color="error" variant="text" :loading="loading" @click="fetchData">
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VAlert>
|
||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
v-if="workflowList.length > 0 && isRefreshed"
|
v-if="workflowList.length > 0 && isRefreshed"
|
||||||
:items="workflowList"
|
:items="workflowList"
|
||||||
@@ -85,7 +129,7 @@ defineExpose({
|
|||||||
</template>
|
</template>
|
||||||
</ProgressiveCardGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="workflowList.length === 0 && isRefreshed"
|
v-if="workflowList.length === 0 && isRefreshed && !loadFailed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('workflow.noWorkflow')"
|
:error-title="t('workflow.noWorkflow')"
|
||||||
:error-description="t('workflow.noWorkflowDescription')"
|
:error-description="t('workflow.noWorkflowDescription')"
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import type { Workflow } from '@/api/types'
|
||||||
|
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { defineComponent, h, ref, type Component, type PropType } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
keepAliveRefresh: undefined as undefined | (() => unknown),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
||||||
|
useKeepAliveRefresh: (handler: () => unknown) => {
|
||||||
|
mocks.keepAliveRefresh = handler
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const LoadingBannerStub = defineComponent({
|
||||||
|
name: 'LoadingBanner',
|
||||||
|
template: '<div role="status">正在加载工作流</div>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const NoDataFoundStub = defineComponent({
|
||||||
|
name: 'NoDataFound',
|
||||||
|
props: {
|
||||||
|
errorCode: String,
|
||||||
|
errorDescription: String,
|
||||||
|
errorTitle: String,
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<section role="region" aria-label="工作流状态" :data-error-code="errorCode">{{ errorTitle }} {{ errorDescription }}<slot name="button" /></section>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
name: 'VBtn',
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup(_props, { attrs, slots }) {
|
||||||
|
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProgressiveCardGridStub = defineComponent({
|
||||||
|
name: 'ProgressiveCardGrid',
|
||||||
|
props: {
|
||||||
|
getItemKey: { type: Function as PropType<(item: Workflow) => string | undefined>, required: true },
|
||||||
|
items: { type: Array as PropType<Workflow[]>, required: true },
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{ 'data-testid': 'workflow-grid' },
|
||||||
|
props.items.flatMap(item => {
|
||||||
|
props.getItemKey(item)
|
||||||
|
return slots.default?.({ item }) ?? []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorkflowTaskCardStub = defineComponent({
|
||||||
|
name: 'WorkflowTaskCard',
|
||||||
|
props: {
|
||||||
|
workflow: { type: Object as PropType<Workflow>, required: true },
|
||||||
|
},
|
||||||
|
emits: ['refresh'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () =>
|
||||||
|
h('article', { 'data-testid': `workflow-card-${props.workflow.id}` }, [
|
||||||
|
h('span', props.workflow.name),
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
'aria-label': `刷新-${props.workflow.id}`,
|
||||||
|
onClick: () => emit('refresh'),
|
||||||
|
type: 'button',
|
||||||
|
},
|
||||||
|
'刷新',
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const WorkflowListHost = defineComponent({
|
||||||
|
name: 'WorkflowListHost',
|
||||||
|
components: { WorkflowListView },
|
||||||
|
setup() {
|
||||||
|
const view = ref<{ openAddDialog: () => void } | null>(null)
|
||||||
|
const openAddDialog = () => {
|
||||||
|
view.value?.openAddDialog()
|
||||||
|
}
|
||||||
|
return { openAddDialog, view }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<WorkflowListView ref="view" />
|
||||||
|
<button type="button" @click="openAddDialog">打开新增</button>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createWorkflow(overrides: Partial<Workflow> = {}): Workflow {
|
||||||
|
return {
|
||||||
|
actions: [{ id: 'scan', name: '扫描目录', type: 'ScanFile' }],
|
||||||
|
id: 'workflow-1',
|
||||||
|
name: '扫描和刮削',
|
||||||
|
state: 'W',
|
||||||
|
trigger_type: 'manual',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRequests() {
|
||||||
|
return mocks.apiGet.mock.calls.filter(([endpoint]) => endpoint === 'workflow/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderList(component: Component = WorkflowListView) {
|
||||||
|
return renderWithProviders(component, {
|
||||||
|
initialRoute: '/workflow',
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
LoadingBanner: LoadingBannerStub,
|
||||||
|
NoDataFound: NoDataFoundStub,
|
||||||
|
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||||
|
VBtn: ButtonStub,
|
||||||
|
WorkflowTaskCard: WorkflowTaskCardStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkflowListView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
mocks.keepAliveRefresh = undefined
|
||||||
|
mocks.openSharedDialog.mockReset()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads event types and workflows on the initial mount', async () => {
|
||||||
|
const workflow = createWorkflow()
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return [{ title: '下载完成', value: 'download.completed' }]
|
||||||
|
if (endpoint === 'workflow/') return [workflow]
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
|
||||||
|
expect(await screen.findByText(workflow.name!)).toBeInTheDocument()
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('workflow/event_types')
|
||||||
|
expect(mocks.apiGet).toHaveBeenCalledWith('workflow/')
|
||||||
|
expect(listRequests()).toHaveLength(1)
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty state for a successful empty workflow response', async () => {
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint === 'workflow/') return []
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('region', { name: '工作流状态' })).toHaveTextContent('没有工作流')
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('workflow-grid')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the loading state and offers same-page retry after an initial failure', async () => {
|
||||||
|
const workflow = createWorkflow({ name: '恢复后的工作流' })
|
||||||
|
let workflowAttempt = 0
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint === 'workflow/') {
|
||||||
|
workflowAttempt += 1
|
||||||
|
if (workflowAttempt === 1) return Promise.reject(new Error('network unavailable'))
|
||||||
|
return [workflow]
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
|
||||||
|
const retry = await screen.findByRole('button', { name: '重试' })
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
await fireEvent.click(retry)
|
||||||
|
expect(await screen.findByText(workflow.name!)).toBeInTheDocument()
|
||||||
|
expect(listRequests()).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the workflow list when a card emits refresh', async () => {
|
||||||
|
const workflow = createWorkflow()
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint === 'workflow/') return [workflow]
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
await screen.findByText(workflow.name!)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '刷新-workflow-1' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(listRequests()).toHaveLength(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps stale workflows visible and offers retry when a refresh fails', async () => {
|
||||||
|
const staleWorkflow = createWorkflow({ name: '已有工作流' })
|
||||||
|
const recoveredWorkflow = createWorkflow({ name: '恢复后的工作流' })
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint === 'workflow/') return [staleWorkflow]
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
expect(await screen.findByText(staleWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/') return Promise.reject(new Error('network unavailable'))
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
await mocks.keepAliveRefresh?.()
|
||||||
|
|
||||||
|
const retry = await screen.findByRole('button', { name: '重试' })
|
||||||
|
expect(screen.getByText(staleWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/') return [recoveredWorkflow]
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
await fireEvent.click(retry)
|
||||||
|
|
||||||
|
expect(await screen.findByText(recoveredWorkflow.name!)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(staleWorkflow.name!)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an older failed refresh after a newer request succeeds', async () => {
|
||||||
|
const initialWorkflow = createWorkflow({ name: '初始工作流' })
|
||||||
|
const latestWorkflow = createWorkflow({ name: '最新工作流' })
|
||||||
|
let rejectOlder!: (reason: Error) => void
|
||||||
|
let requestCount = 0
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint !== 'workflow/') throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
requestCount += 1
|
||||||
|
if (requestCount === 1) return [initialWorkflow]
|
||||||
|
if (requestCount === 2) {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
rejectOlder = reject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [latestWorkflow]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
expect(await screen.findByText(initialWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const olderRefresh = mocks.keepAliveRefresh?.()
|
||||||
|
await mocks.keepAliveRefresh?.()
|
||||||
|
expect(await screen.findByText(latestWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
rejectOlder(new Error('stale network failure'))
|
||||||
|
await olderRefresh
|
||||||
|
|
||||||
|
expect(screen.getByText(latestWorkflow.name!)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an older successful response after a newer request succeeds', async () => {
|
||||||
|
const initialWorkflow = createWorkflow({ name: '初始工作流' })
|
||||||
|
const staleWorkflow = createWorkflow({ name: '迟到工作流' })
|
||||||
|
const latestWorkflow = createWorkflow({ name: '最新工作流' })
|
||||||
|
let resolveOlder!: (value: Workflow[]) => void
|
||||||
|
let requestCount = 0
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint !== 'workflow/') throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
requestCount += 1
|
||||||
|
if (requestCount === 1) return [initialWorkflow]
|
||||||
|
if (requestCount === 2) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
resolveOlder = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [latestWorkflow]
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderList()
|
||||||
|
expect(await screen.findByText(initialWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const olderRefresh = mocks.keepAliveRefresh?.()
|
||||||
|
await mocks.keepAliveRefresh?.()
|
||||||
|
expect(await screen.findByText(latestWorkflow.name!)).toBeInTheDocument()
|
||||||
|
|
||||||
|
resolveOlder([staleWorkflow])
|
||||||
|
await olderRefresh
|
||||||
|
|
||||||
|
expect(screen.getByText(latestWorkflow.name!)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(staleWorkflow.name!)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the workflow list after the add dialog emits save', async () => {
|
||||||
|
const workflow = createWorkflow()
|
||||||
|
let saveHandler: (() => unknown) | undefined
|
||||||
|
mocks.apiGet.mockImplementation((endpoint: string) => {
|
||||||
|
if (endpoint === 'workflow/event_types') return []
|
||||||
|
if (endpoint === 'workflow/') return [workflow]
|
||||||
|
throw new Error(`Unexpected GET ${endpoint}`)
|
||||||
|
})
|
||||||
|
mocks.openSharedDialog.mockImplementation(
|
||||||
|
(_component: unknown, _props: unknown, events: { save?: () => unknown }) => {
|
||||||
|
saveHandler = events.save
|
||||||
|
return { close: vi.fn(), id: 1, updateProps: vi.fn() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderList(WorkflowListHost)
|
||||||
|
await screen.findByText(workflow.name!)
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '打开新增' }))
|
||||||
|
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
saveHandler?.()
|
||||||
|
await waitFor(() => expect(listRequests()).toHaveLength(2))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -399,6 +399,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/components/cards/PluginFolderCard.vue',
|
'src/components/cards/PluginFolderCard.vue',
|
||||||
'src/components/cards/PluginCard.vue',
|
'src/components/cards/PluginCard.vue',
|
||||||
'src/components/cards/PluginAppCard.vue',
|
'src/components/cards/PluginAppCard.vue',
|
||||||
|
'src/components/cards/WorkflowTaskCard.vue',
|
||||||
'src/components/dialog/PluginMarketDetailDialog.vue',
|
'src/components/dialog/PluginMarketDetailDialog.vue',
|
||||||
'src/components/dialog/PluginMarketSettingDialog.vue',
|
'src/components/dialog/PluginMarketSettingDialog.vue',
|
||||||
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
||||||
@@ -416,6 +417,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/views/setting/AccountSettingSubscribe.vue',
|
'src/views/setting/AccountSettingSubscribe.vue',
|
||||||
'src/views/setting/AccountSettingSystem.vue',
|
'src/views/setting/AccountSettingSystem.vue',
|
||||||
'src/views/user/UserListView.vue',
|
'src/views/user/UserListView.vue',
|
||||||
|
'src/views/workflow/WorkflowListView.vue',
|
||||||
'src/views/user/UserProfileView.vue',
|
'src/views/user/UserProfileView.vue',
|
||||||
'src/views/reorganize/DownloadingListView.vue',
|
'src/views/reorganize/DownloadingListView.vue',
|
||||||
'src/views/plugin/PluginCardListView.vue',
|
'src/views/plugin/PluginCardListView.vue',
|
||||||
@@ -543,6 +545,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
},
|
},
|
||||||
|
'src/components/cards/WorkflowTaskCard.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
'src/components/dialog/UserAddEditDialog.vue': {
|
'src/components/dialog/UserAddEditDialog.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
@@ -567,6 +575,12 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
},
|
},
|
||||||
|
'src/views/workflow/WorkflowListView.vue': {
|
||||||
|
branches: 80,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
statements: 85,
|
||||||
|
},
|
||||||
'src/views/user/UserProfileView.vue': {
|
'src/views/user/UserProfileView.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user