fix(workflow): recover list refresh failures (#702)

This commit is contained in:
InfinityPacer
2026-08-23 21:04:13 +08:00
committed by GitHub
parent f2ecc6af73
commit 72dc35ff41
4 changed files with 601 additions and 5 deletions
@@ -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')
+48 -4
View File
@@ -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))
})
})
+14
View File
@@ -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,