From ae99b884c60aa2bd29127d8f009f424765913ed1 Mon Sep 17 00:00:00 2001 From: eezd Date: Mon, 26 Oct 2026 13:55:54 +0800 Subject: [PATCH] fix(plugins): propagate refresh failures to their owning callers --- web/src/common/plugins/host.test.ts | 72 ++++++++++- web/src/common/plugins/host.ts | 20 +-- .../settings/components/MaintenancePanel.vue | 28 +++-- .../settings/components/PluginsPanel.vue | 22 ++-- web/src/pages/settings/error-handling.test.ts | 114 ++++++++++++++++++ web/src/pages/workspace/workspace.ts | 6 +- 6 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 web/src/pages/settings/error-handling.test.ts diff --git a/web/src/common/plugins/host.test.ts b/web/src/common/plugins/host.test.ts index a2c571b..b953dd1 100644 --- a/web/src/common/plugins/host.test.ts +++ b/web/src/common/plugins/host.test.ts @@ -1,13 +1,15 @@ +import type { PluginsResponseData } from '@@/apis/plugins/type' import type { PluginAPI } from '@@/plugin-api' import * as pluginsApi from '@@/apis/plugins' // @vitest-environment happy-dom +import { CanceledError, isCancel } from 'axios' import { afterEach, expect, it, vi } from 'vitest' import { pinia } from '@/pinia' import { useSettingsStore } from '@/pinia/stores/settings' import { apiSuccess } from '../../../tests/api-response' import { allCommands, commandKeys } from '../utils/commands' -import { pluginState, startPlugins, stopPlugins } from './host' +import { pluginState, refreshPlugins, retryPlugin, startPlugins, stopPlugins } from './host' const { settings } = useSettingsStore(pinia) @@ -22,7 +24,7 @@ afterEach(() => { }) 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[] = [] startPlugins({ notice: message => notices.push(message), getActiveNote: () => null, openNote: async () => {}, writeNote: async () => ({ rev: 'r1' }) }) await vi.waitFor(() => expect(pluginState.status.fault?.phase).toBe('active')) @@ -36,6 +38,72 @@ it('命令失败保留原始异常并清理注册,正常插件列表读取不 expect(pluginState.status.fault?.phase).toBe('error') expect(allCommands().some(command => command.id === 'plugin:fault:fail')).toBe(false) 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', () => ({ diff --git a/web/src/common/plugins/host.ts b/web/src/common/plugins/host.ts index 0e85829..7720bb6 100644 --- a/web/src/common/plugins/host.ts +++ b/web/src/common/plugins/host.ts @@ -5,6 +5,7 @@ import { getTreeApi } from '@@/apis/tree' import { readNoteFile } from '@@/utils/note-files' import { pluginResourceURL } from '@@/utils/resource-tickets' +import { CanceledError, isCancel } from 'axios' import { reactive } from 'vue' import { allCommands, registerCommand } from '../utils/commands' import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry' @@ -255,12 +256,12 @@ async function pump() { } export async function refreshPlugins() { - if (!running) return + if (!running) throw new CanceledError('插件宿主已停止') const currentSession = session const sequence = ++request try { 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.error = '' pluginState.errorCause = undefined @@ -276,23 +277,26 @@ export async function refreshPlugins() { } void pump() } catch (cause) { - if (!running || currentSession !== session || sequence !== request) return - pluginState.error = '读取插件列表失败,请检查连接后重试' + if (!running || currentSession !== session || sequence !== request) throw new CanceledError('插件刷新已过期') + if (isCancel(cause)) throw cause + pluginState.error = `读取插件列表失败:${cause instanceof Error ? cause.message : String(cause)}` pluginState.errorCause = cause - adapter.notice(pluginState.error) for (const instance of instances.values()) { status(instance.info.id, 'paused') void stop(instance) } + throw cause } } export function startPlugins(value: Adapter) { adapter = value running = true - session++ + const currentSession = ++session faults.clear() - void refreshPlugins() + void refreshPlugins().catch((cause) => { + if (running && currentSession === session && !isCancel(cause)) value.notice(pluginState.error) + }) } export function stopPlugins() { @@ -305,7 +309,7 @@ export function stopPlugins() { export function retryPlugin(id: string) { faults.delete(id) - void refreshPlugins() + return refreshPlugins() } export function pluginEditorFailure(error: unknown) { diff --git a/web/src/pages/settings/components/MaintenancePanel.vue b/web/src/pages/settings/components/MaintenancePanel.vue index 5a39452..a84c582 100644 --- a/web/src/pages/settings/components/MaintenancePanel.vue +++ b/web/src/pages/settings/components/MaintenancePanel.vue @@ -44,14 +44,15 @@ onMounted(async () => { async function prepare() { await Promise.all([props.prepareMaintenance(), flushSettings()]) } -async function perform(label: string, action: () => Promise) { +async function perform(label: string, action: (version: number) => Promise) { if (busy.value) return const version = user.getSessionVersion() busy.value = true; progress.value = label; error.value = ''; message.value = '' try { await prepare() user.assertSession(version) - await action() + await action(version) + user.assertSession(version) if (mounted) { emit('changed'); last.value = (await getMaintenanceStatusApi()).data } @@ -85,8 +86,21 @@ async function exportMarkdown() { async function exportSystemLogs() { await perform('正在导出日志…', async () => download(await exportLogsApi(), 'rawmark-system-logs.zip')) } -async function syncPreferences() { - await loadSettings(); await reloadTheme(); await refreshPlugins() +async function syncPreferences(completed: string, version: number) { + 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() { if (busy.value) return @@ -98,8 +112,8 @@ async function reset() { busy.value = false } if (!confirmed) return - await perform('正在重置设置…', async () => { - await resetSettingsApi(); await syncPreferences(); message.value = '设置已重置,原设置已备份' + await perform('正在重置设置…', async (version) => { + await resetSettingsApi(); await syncPreferences('设置已重置,原设置已备份', version) }) } async function restore(kind: BackupKind, event: Event) { @@ -132,7 +146,7 @@ async function restore(kind: BackupKind, event: Event) { progress.value = kind === 'full' ? '正在恢复实例,请勿关闭页面…' : '正在导入设置…' if (kind === 'settings') { await importSettingsApi(id); submitted = true - await syncPreferences(); message.value = '设置与资源已导入,原设置已备份' + await syncPreferences('设置与资源已导入,原设置已备份', version) } else { await restoreBackupApi(id); submitted = true user.invalidate(version) diff --git a/web/src/pages/settings/components/PluginsPanel.vue b/web/src/pages/settings/components/PluginsPanel.vue index 11ad73b..705bfc4 100644 --- a/web/src/pages/settings/components/PluginsPanel.vue +++ b/web/src/pages/settings/components/PluginsPanel.vue @@ -9,7 +9,6 @@ import { pluginsPaused, settingTabs } from '@@/plugins/registry' import { confirmAction } from '@@/utils/confirm' import { isCancel } from 'axios' import { computed, onMounted, ref } from 'vue' -import { ApiError } from '@/http/axios' const busy = ref(false) const error = ref('') @@ -18,17 +17,16 @@ const selected = ref('') const currentTab = computed(() => settingTabs.get(selected.value)) const labels: Record = { loading: '加载中', active: '运行中', error: '故障停用', disabled: '已停用', paused: '临时跳过' } -async function perform(operation?: () => Promise, confirmation?: string) { +async function perform(operation: () => Promise = refreshPlugins, confirmation?: string) { if (busy.value) return busy.value = true error.value = '' try { if (confirmation && !await confirmAction(confirmation)) return - if (operation) await operation() - await refreshPlugins() + await operation() } catch (err) { if (isCancel(err)) return - error.value = err instanceof ApiError ? err.message : '插件操作失败,请检查连接后重试' + error.value = err instanceof Error ? err.message : String(err) } finally { busy.value = false } @@ -39,15 +37,21 @@ async function install(event: Event) { const file = input.files?.[0] input.value = '' if (!file) return - await perform(() => installPluginApi(file), '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?') + await perform(async () => { + await installPluginApi(file); await refreshPlugins() + }, '插件在页面内拥有完整权限,可读取和修改全部笔记及账户数据。仅安装你信任的插件。确认安装?') } 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) { - void perform(() => togglePluginApi({ id: plugin.id, enabled: !plugin.enabled })) + void perform(async () => { + await togglePluginApi({ id: plugin.id, enabled: !plugin.enabled }); await refreshPlugins() + }) } onMounted(() => perform()) @@ -93,7 +97,7 @@ onMounted(() => perform())