fix(plugins): propagate refresh failures to their owning callers
This commit is contained in:
1 parent
b561b398b4
commit
ae99b884c6
6 files changed
+233
-29
No files matched your search
@@ -1,13 +1,15 @@
|
|||||||
|
import type { PluginsResponseData } from '@@/apis/plugins/type'
|
||||||
import type { PluginAPI } from '@@/plugin-api'
|
import type { PluginAPI } from '@@/plugin-api'
|
||||||
import * as pluginsApi from '@@/apis/plugins'
|
import * as pluginsApi from '@@/apis/plugins'
|
||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
import { CanceledError, isCancel } from 'axios'
|
||||||
import { afterEach, expect, it, vi } from 'vitest'
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { pinia } from '@/pinia'
|
import { pinia } from '@/pinia'
|
||||||
import { useSettingsStore } from '@/pinia/stores/settings'
|
import { useSettingsStore } from '@/pinia/stores/settings'
|
||||||
import { apiSuccess } from '../../../tests/api-response'
|
import { apiSuccess } from '../../../tests/api-response'
|
||||||
import { allCommands, commandKeys } from '../utils/commands'
|
import { allCommands, commandKeys } from '../utils/commands'
|
||||||
import { pluginState, startPlugins, stopPlugins } from './host'
|
import { pluginState, refreshPlugins, retryPlugin, startPlugins, stopPlugins } from './host'
|
||||||
|
|
||||||
const { settings } = useSettingsStore(pinia)
|
const { settings } = useSettingsStore(pinia)
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('命令失败保留原始异常并清理注册,正常插件列表读取不受影响', async () => {
|
it('命令失败保留原始异常并清理注册,正常插件列表读取不受影响', async () => {
|
||||||
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [{ id: 'fault', name: '故障插件', version: '1', apiVersion: 1, revision: 'rev', enabled: true }] }))
|
const list = vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [{ id: 'fault', name: '故障插件', version: '1', apiVersion: 1, revision: 'rev', enabled: true }] }))
|
||||||
const notices: string[] = []
|
const notices: string[] = []
|
||||||
startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
|
startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
|
||||||
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
|
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
|
||||||
@@ -36,6 +38,72 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不
|
|||||||
expect(pluginState.status.fault?.phase).toBe('error')
|
expect(pluginState.status.fault?.phase).toBe('error')
|
||||||
expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false)
|
expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false)
|
||||||
expect(notices.some(message => message.includes('命令失败'))).toBe(true)
|
expect(notices.some(message => message.includes('命令失败'))).toBe(true)
|
||||||
|
list.mockClear()
|
||||||
|
await retryPlugin('fault')
|
||||||
|
await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active'))
|
||||||
|
expect(list).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('交互刷新失败保留原因并 reject,不从宿主重复通知', async () => {
|
||||||
|
const list = vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
|
||||||
|
const notice = vi.fn()
|
||||||
|
startPlugins({ notice, getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
|
||||||
|
await vi.waitFor(() => expect(pluginState.error).toBe(''))
|
||||||
|
const failure = new Error('插件磁盘不可读')
|
||||||
|
list.mockRejectedValueOnce(failure)
|
||||||
|
await expect(refreshPlugins()).rejects.toBe(failure)
|
||||||
|
expect(pluginState.errorCause).toBe(failure)
|
||||||
|
expect(pluginState.error).toContain(failure.message)
|
||||||
|
expect(notice).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('启动失败由入口通知一次,取消刷新不产生故障状态', async () => {
|
||||||
|
const list = vi.spyOn(pluginsApi, 'getPluginsApi').mockRejectedValueOnce(new Error('启动读取失败'))
|
||||||
|
const notice = vi.fn()
|
||||||
|
startPlugins({ notice, getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) })
|
||||||
|
await vi.waitFor(() => expect(notice).toHaveBeenCalledOnce())
|
||||||
|
list.mockResolvedValueOnce(apiSuccess({ plugins: [] }))
|
||||||
|
await refreshPlugins()
|
||||||
|
list.mockRejectedValueOnce(new CanceledError('会话已变化'))
|
||||||
|
await expect(refreshPlugins()).rejects.toSatisfy(isCancel)
|
||||||
|
expect(pluginState.error).toBe('')
|
||||||
|
expect(notice).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('旧宿主刷新即使最后到达,也必须按取消拒绝且不覆盖新宿主', async () => {
|
||||||
|
let finish!: (response: PluginsResponseData) => void
|
||||||
|
const list = vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] }))
|
||||||
|
const notice = vi.fn()
|
||||||
|
const adapter = { notice, getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) }
|
||||||
|
startPlugins(adapter)
|
||||||
|
await vi.waitFor(() => expect(pluginState.error).toBe(''))
|
||||||
|
list.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
}))
|
||||||
|
const old = refreshPlugins()
|
||||||
|
const canceled = expect(old).rejects.toSatisfy(isCancel)
|
||||||
|
stopPlugins()
|
||||||
|
startPlugins(adapter)
|
||||||
|
await refreshPlugins()
|
||||||
|
finish(apiSuccess({ plugins: [{ id: 'old', name: '旧插件', version: '1', apiVersion: 1, revision: 'rev', enabled: false }] }))
|
||||||
|
await canceled
|
||||||
|
expect(pluginState.installed).toEqual([])
|
||||||
|
expect(notice).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('旧启动已失败但通知尚未执行时,切换宿主也不能向新页面提示', async () => {
|
||||||
|
vi.spyOn(pluginsApi, 'getPluginsApi').mockResolvedValue(apiSuccess({ plugins: [] })).mockRejectedValueOnce(new Error('旧启动失败'))
|
||||||
|
const oldNotice = vi.fn()
|
||||||
|
const newNotice = vi.fn()
|
||||||
|
const adapter = { getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) }
|
||||||
|
startPlugins({ ...adapter, notice: oldNotice })
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(pluginState.error).toContain('旧启动失败')
|
||||||
|
stopPlugins()
|
||||||
|
startPlugins({ ...adapter, notice: newNotice })
|
||||||
|
await vi.waitFor(() => expect(pluginState.error).toBe(''))
|
||||||
|
expect(oldNotice).not.toHaveBeenCalled()
|
||||||
|
expect(newNotice).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@@/utils/resource-tickets', () => ({
|
vi.mock('@@/utils/resource-tickets', () => ({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getTreeApi } from '@@/apis/tree'
|
|||||||
import { readNoteFile } from '@@/utils/note-files'
|
import { readNoteFile } from '@@/utils/note-files'
|
||||||
import { pluginResourceURL } from '@@/utils/resource-tickets'
|
import { pluginResourceURL } from '@@/utils/resource-tickets'
|
||||||
|
|
||||||
|
import { CanceledError, isCancel } from 'axios'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { allCommands, registerCommand } from '../utils/commands'
|
import { allCommands, registerCommand } from '../utils/commands'
|
||||||
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
|
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
|
||||||
@@ -255,12 +256,12 @@ async function pump() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshPlugins() {
|
export async function refreshPlugins() {
|
||||||
if (!running) return
|
if (!running) throw new CanceledError('插件宿主已停止')
|
||||||
const currentSession = session
|
const currentSession = session
|
||||||
const sequence = ++request
|
const sequence = ++request
|
||||||
try {
|
try {
|
||||||
const installed = (await getPluginsApi()).data.plugins
|
const installed = (await getPluginsApi()).data.plugins
|
||||||
if (!running || currentSession !== session || sequence !== request) return
|
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
|
||||||
pluginState.installed = installed
|
pluginState.installed = installed
|
||||||
pluginState.error = ''
|
pluginState.error = ''
|
||||||
pluginState.errorCause = undefined
|
pluginState.errorCause = undefined
|
||||||
@@ -276,23 +277,26 @@ export async function refreshPlugins() {
|
|||||||
}
|
}
|
||||||
void pump()
|
void pump()
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!running || currentSession !== session || sequence !== request) return
|
if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期')
|
||||||
pluginState.error = '读取插件列表失败,请检查连接后重试'
|
if (isCancel(cause)) throw cause
|
||||||
|
pluginState.error = `读取插件列表失败:${cause instanceof Error ? cause.message : String(cause)}`
|
||||||
pluginState.errorCause = cause
|
pluginState.errorCause = cause
|
||||||
adapter.notice(pluginState.error)
|
|
||||||
for (const instance of instances.values()) {
|
for (const instance of instances.values()) {
|
||||||
status(instance.info.id, 'paused')
|
status(instance.info.id, 'paused')
|
||||||
void stop(instance)
|
void stop(instance)
|
||||||
}
|
}
|
||||||
|
throw cause
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startPlugins(value: Adapter) {
|
export function startPlugins(value: Adapter) {
|
||||||
adapter = value
|
adapter = value
|
||||||
running = true
|
running = true
|
||||||
session++
|
const currentSession = ++session
|
||||||
faults.clear()
|
faults.clear()
|
||||||
void refreshPlugins()
|
void refreshPlugins().catch((cause) => {
|
||||||
|
if (running && currentSession === session && !isCancel(cause)) value.notice(pluginState.error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopPlugins() {
|
export function stopPlugins() {
|
||||||
@@ -305,7 +309,7 @@ export function stopPlugins() {
|
|||||||
|
|
||||||
export function retryPlugin(id: string) {
|
export function retryPlugin(id: string) {
|
||||||
faults.delete(id)
|
faults.delete(id)
|
||||||
void refreshPlugins()
|
return refreshPlugins()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pluginEditorFailure(error: unknown) {
|
export function pluginEditorFailure(error: unknown) {
|
||||||
|
|||||||
@@ -44,14 +44,15 @@ onMounted(async () => {
|
|||||||
async function prepare() {
|
async function prepare() {
|
||||||
await Promise.all([props.prepareMaintenance(), flushSettings()])
|
await Promise.all([props.prepareMaintenance(), flushSettings()])
|
||||||
}
|
}
|
||||||
async function perform(label: string, action: () => Promise<void>) {
|
async function perform(label: string, action: (version: number) => Promise<void>) {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const version = user.getSessionVersion()
|
const version = user.getSessionVersion()
|
||||||
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
busy.value = true; progress.value = label; error.value = ''; message.value = ''
|
||||||
try {
|
try {
|
||||||
await prepare()
|
await prepare()
|
||||||
user.assertSession(version)
|
user.assertSession(version)
|
||||||
await action()
|
await action(version)
|
||||||
|
user.assertSession(version)
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
emit('changed'); last.value = (await getMaintenanceStatusApi()).data
|
emit('changed'); last.value = (await getMaintenanceStatusApi()).data
|
||||||
}
|
}
|
||||||
@@ -85,8 +86,21 @@ async function exportMarkdown() {
|
|||||||
async function exportSystemLogs() {
|
async function exportSystemLogs() {
|
||||||
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
|
await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip'))
|
||||||
}
|
}
|
||||||
async function syncPreferences() {
|
async function syncPreferences(completed: string, version: number) {
|
||||||
await loadSettings(); await reloadTheme(); await refreshPlugins()
|
try {
|
||||||
|
user.assertSession(version)
|
||||||
|
await loadSettings()
|
||||||
|
user.assertSession(version)
|
||||||
|
await reloadTheme()
|
||||||
|
user.assertSession(version)
|
||||||
|
await refreshPlugins()
|
||||||
|
user.assertSession(version)
|
||||||
|
} catch (cause) {
|
||||||
|
user.assertSession(version)
|
||||||
|
if (isCancel(cause)) throw cause
|
||||||
|
throw new Error(`${completed},但同步失败:${cause instanceof Error ? cause.message : String(cause)}`, { cause })
|
||||||
|
}
|
||||||
|
message.value = completed
|
||||||
}
|
}
|
||||||
async function reset() {
|
async function reset() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
@@ -98,8 +112,8 @@ async function reset() {
|
|||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await perform('正在重置设置…', async () => {
|
await perform('正在重置设置…', async (version) => {
|
||||||
await resetSettingsApi(); await syncPreferences(); message.value = '设置已重置,原设置已备份'
|
await resetSettingsApi(); await syncPreferences('设置已重置,原设置已备份', version)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async function restore(kind: BackupKind, event: Event) {
|
async function restore(kind: BackupKind, event: Event) {
|
||||||
@@ -132,7 +146,7 @@ async function restore(kind: BackupKind, event: Event) {
|
|||||||
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…'
|
||||||
if (kind === 'settings') {
|
if (kind === 'settings') {
|
||||||
await importSettingsApi(id); submitted = true
|
await importSettingsApi(id); submitted = true
|
||||||
await syncPreferences(); message.value = '设置与资源已导入,原设置已备份'
|
await syncPreferences('设置与资源已导入,原设置已备份', version)
|
||||||
} else {
|
} else {
|
||||||
await restoreBackupApi(id); submitted = true
|
await restoreBackupApi(id); submitted = true
|
||||||
user.invalidate(version)
|
user.invalidate(version)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { pluginsPaused, settingTabs } from '@@/plugins/registry'
|
|||||||
import { confirmAction } from '@@/utils/confirm'
|
import { confirmAction } from '@@/utils/confirm'
|
||||||
import { isCancel } from 'axios'
|
import { isCancel } from 'axios'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ApiError } from '@/http/axios'
|
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -18,17 +17,16 @@ const selected = ref('')
|
|||||||
const currentTab = computed(() => settingTabs.get(selected.value))
|
const currentTab = computed(() => settingTabs.get(selected.value))
|
||||||
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
const labels: Record<string, string> = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' }
|
||||||
|
|
||||||
async function perform(operation?: () => Promise<unknown>, confirmation?: string) {
|
async function perform(operation: () => Promise<unknown> = refreshPlugins, confirmation?: string) {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
if (confirmation && !await confirmAction(confirmation)) return
|
if (confirmation && !await confirmAction(confirmation)) return
|
||||||
if (operation) await operation()
|
await operation()
|
||||||
await refreshPlugins()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isCancel(err)) return
|
if (isCancel(err)) return
|
||||||
error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试'
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
@@ -39,15 +37,21 @@ async function install(event: Event) {
|
|||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
input.value = ''
|
input.value = ''
|
||||||
if (!file) return
|
if (!file) return
|
||||||
await perform(() => installPluginApi(file), '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
|
await perform(async () => {
|
||||||
|
await installPluginApi(file); await refreshPlugins()
|
||||||
|
}, '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(plugin: InstalledPlugin) {
|
async function remove(plugin: InstalledPlugin) {
|
||||||
await perform(() => uninstallPluginApi(plugin.id), `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
|
await perform(async () => {
|
||||||
|
await uninstallPluginApi(plugin.id); await refreshPlugins()
|
||||||
|
}, `卸载插件“${plugin.name}”?将停用并删除插件文件,保留插件数据。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(plugin: InstalledPlugin) {
|
function toggle(plugin: InstalledPlugin) {
|
||||||
void perform(() => togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }))
|
void perform(async () => {
|
||||||
|
await togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }); await refreshPlugins()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onMounted(() => perform())
|
onMounted(() => perform())
|
||||||
</script>
|
</script>
|
||||||
@@ -93,7 +97,7 @@ onMounted(() => perform())
|
|||||||
<!-- @vue-generic {InstalledPlugin} -->
|
<!-- @vue-generic {InstalledPlugin} -->
|
||||||
<el-table-column label="操作" width="130">
|
<el-table-column label="操作" width="130">
|
||||||
<template #default="{ row: plugin }">
|
<template #default="{ row: plugin }">
|
||||||
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="retryPlugin(plugin.id)">
|
<el-button v-if="plugin.enabled && pluginState.status[plugin.id]?.phase === 'error'" text :disabled="busy || pluginsPaused" @click="perform(() => retryPlugin(plugin.id))">
|
||||||
重试
|
重试
|
||||||
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
|
</el-button><el-button text type="danger" :disabled="busy" @click="remove(plugin)">
|
||||||
卸载
|
卸载
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import * as maintenanceApi from '@@/apis/maintenance'
|
||||||
|
import * as plugins from '@@/plugins/host'
|
||||||
|
import { confirmAction } from '@@/utils/confirm'
|
||||||
|
import { toasts } from '@@/utils/notifications'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { CanceledError } from 'axios'
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { pinia } from '@/pinia'
|
||||||
|
import { useUserStore } from '@/pinia/stores/user'
|
||||||
|
import { apiSuccess } from '../../../tests/api-response'
|
||||||
|
import MaintenancePanel from './components/MaintenancePanel.vue'
|
||||||
|
import PluginsPanel from './components/PluginsPanel.vue'
|
||||||
|
|
||||||
|
const settings = vi.hoisted(() => ({ flushSettings: vi.fn(), loadSettings: vi.fn(), reloadTheme: vi.fn() }))
|
||||||
|
vi.mock('@/pinia/stores/settings', () => ({ useSettingsStore: () => settings }))
|
||||||
|
vi.mock('@@/plugins/host', () => ({
|
||||||
|
refreshPlugins: vi.fn(),
|
||||||
|
retryPlugin: vi.fn(),
|
||||||
|
pluginState: { installed: [], status: {}, error: '' },
|
||||||
|
}))
|
||||||
|
vi.mock('@@/utils/confirm', () => ({ confirmAction: vi.fn() }))
|
||||||
|
|
||||||
|
const user = useUserStore(pinia)
|
||||||
|
const button = defineComponent({ template: '<button><slot /></button>' })
|
||||||
|
const global = { plugins: [pinia], stubs: { ElButton: button, ElTable: true } }
|
||||||
|
let wrapper: VueWrapper
|
||||||
|
beforeEach(() => {
|
||||||
|
user.setToken('rms_settings')
|
||||||
|
toasts.splice(0)
|
||||||
|
vi.mocked(plugins.refreshPlugins).mockResolvedValue(undefined)
|
||||||
|
vi.mocked(confirmAction).mockResolvedValue(true)
|
||||||
|
settings.flushSettings.mockResolvedValue(undefined)
|
||||||
|
settings.loadSettings.mockResolvedValue(undefined)
|
||||||
|
settings.reloadTheme.mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(maintenanceApi, 'getMaintenanceStatusApi').mockResolvedValue(apiSuccess({ busy: false, operation: '', error: '', finishedAt: 0 }))
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
user.setToken('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('手动插件刷新只请求一次,失败在当前面板保留原因', async () => {
|
||||||
|
wrapper = mount(PluginsPanel, { global })
|
||||||
|
await flushPromises()
|
||||||
|
vi.mocked(plugins.refreshPlugins).mockClear().mockRejectedValueOnce(new Error('插件文件读取失败'))
|
||||||
|
await wrapper.findAll('button').find(item => item.text() === '刷新列表')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(plugins.refreshPlugins).toHaveBeenCalledOnce()
|
||||||
|
expect(wrapper.findAll('.rm-auth-error')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.rm-auth-error').text()).toContain('插件文件读取失败')
|
||||||
|
expect(toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重置已成功但插件同步失败时,说明已完成的操作,不显示完整成功', async () => {
|
||||||
|
const reset = vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
|
||||||
|
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
|
||||||
|
await flushPromises()
|
||||||
|
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new Error('插件服务暂时不可用'))
|
||||||
|
await wrapper.get('#rm-setting-settings-reset button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(reset).toHaveBeenCalledOnce()
|
||||||
|
expect(wrapper.get('[role="alert"]').text()).toContain('设置已重置,原设置已备份,但同步失败:插件服务暂时不可用')
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('设置重置后的同步被会话切换取消时不提示失败', async () => {
|
||||||
|
vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
|
||||||
|
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
|
||||||
|
await flushPromises()
|
||||||
|
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new CanceledError('会话已变化'))
|
||||||
|
await wrapper.get('#rm-setting-settings-reset button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
expect(toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同步阶段切换会话后,不继续为新会话刷新旧操作的插件或显示成功', async () => {
|
||||||
|
vi.spyOn(maintenanceApi, 'resetSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
|
||||||
|
settings.reloadTheme.mockImplementationOnce(async () => {
|
||||||
|
user.setToken('rms_new_settings')
|
||||||
|
})
|
||||||
|
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('#rm-setting-settings-reset button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(plugins.refreshPlugins).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('设置导入已提交但同步失败时保留完成信息,不取消已提交的上传', async () => {
|
||||||
|
vi.spyOn(maintenanceApi, 'startBackupUploadApi').mockResolvedValue(apiSuccess({ id: 'settings-upload' }))
|
||||||
|
vi.spyOn(maintenanceApi, 'uploadBackupChunkApi').mockResolvedValue(apiSuccess({ offset: 3 }))
|
||||||
|
vi.spyOn(maintenanceApi, 'validateBackupApi').mockResolvedValue(apiSuccess({ kind: 'settings', createdAt: 1, entries: 1, size: 3 }))
|
||||||
|
const imported = vi.spyOn(maintenanceApi, 'importSettingsApi').mockResolvedValue(apiSuccess({ ok: true }))
|
||||||
|
const cancel = vi.spyOn(maintenanceApi, 'cancelBackupUploadApi')
|
||||||
|
vi.mocked(plugins.refreshPlugins).mockRejectedValueOnce(new Error('插件列表读取失败'))
|
||||||
|
wrapper = mount(MaintenancePanel, { props: { prepareMaintenance: async () => {} }, global })
|
||||||
|
await flushPromises()
|
||||||
|
const input = wrapper.get('#rm-setting-backup-settings input')
|
||||||
|
Object.defineProperty(input.element, 'files', { value: [new File(['zip'], 'settings.zip')] })
|
||||||
|
await input.trigger('change')
|
||||||
|
await flushPromises()
|
||||||
|
expect(imported).toHaveBeenCalledExactlyOnceWith('settings-upload')
|
||||||
|
expect(wrapper.get('[role="alert"]').text()).toContain('设置与资源已导入,原设置已备份,但同步失败:插件列表读取失败')
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
expect(cancel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
@@ -993,9 +993,9 @@ async function handleEvents(evs: VaultEvent[]) {
|
|||||||
void loadSettings().catch((err) => {
|
void loadSettings().catch((err) => {
|
||||||
if (!isCancel(err)) toast(`同步设置失败:${errorText(err)}`)
|
if (!isCancel(err)) toast(`同步设置失败:${errorText(err)}`)
|
||||||
})
|
})
|
||||||
void refreshPlugins()
|
await refreshPlugins()
|
||||||
} else if (ev.type === 'plugins') {
|
} else if (ev.type === 'plugins') {
|
||||||
void refreshPlugins()
|
await refreshPlugins()
|
||||||
} else if (ev.type === 'changed') {
|
} else if (ev.type === 'changed') {
|
||||||
const tab = tabOf(ev.path!)
|
const tab = tabOf(ev.path!)
|
||||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||||
@@ -1123,7 +1123,7 @@ export async function startWorkspace() {
|
|||||||
eventQueue = eventQueue.then(() => {
|
eventQueue = eventQueue.then(() => {
|
||||||
if (!stopped) return work()
|
if (!stopped) return work()
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (!isCancel(err)) toast(`同步失败:${errorText(err)}`)
|
if (!stopped && version === user.getSessionVersion() && !isCancel(err)) toast(`同步失败:${errorText(err)}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const stopEvents = connectEventsApi({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
|
const stopEvents = connectEventsApi({ onEvents: events => enqueue(() => handleEvents(events)), onResync: () => enqueue(resync) })
|
||||||
|
|||||||
Reference in new issue
Block a user