diff --git a/frontend/src/views/admin/PluginsView.vue b/frontend/src/views/admin/PluginsView.vue index 6c78fc69e9..7fc17c9b43 100644 --- a/frontend/src/views/admin/PluginsView.vue +++ b/frontend/src/views/admin/PluginsView.vue @@ -380,6 +380,7 @@ const uiError = ref(""); const iframeHeight = ref(640); const pluginFrameLoaded = ref(false); const pendingBridgeRequests = new Map(); +let uiSessionVersion = 0; function errorMessage(error: unknown): string { if (typeof error === "object" && error !== null && "message" in error) { @@ -523,6 +524,7 @@ async function testPlugin(plugin: PluginInstallation): Promise { } async function openConfiguration(plugin: PluginInstallation): Promise { + const version = ++uiSessionVersion; configPlugin.value = plugin; uiSession.value = null; pluginFrameLoaded.value = false; @@ -531,14 +533,18 @@ async function openConfiguration(plugin: PluginInstallation): Promise { uiError.value = ""; iframeHeight.value = 640; try { - uiSession.value = await adminAPI.plugins.createUISession(plugin.id); + const session = await adminAPI.plugins.createUISession(plugin.id); + if (version !== uiSessionVersion) return; + uiSession.value = session; } catch (error: unknown) { + if (version !== uiSessionVersion) return; uiLoading.value = false; uiError.value = errorMessage(error); } } function closeConfiguration(): void { + uiSessionVersion++; clearPendingBridgeRequests(); pluginFrameLoaded.value = false; configPlugin.value = null; @@ -723,6 +729,6 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener("message", handleBridgeMessage); - clearPendingBridgeRequests(); + closeConfiguration(); }); diff --git a/frontend/src/views/admin/__tests__/PluginsView.spec.ts b/frontend/src/views/admin/__tests__/PluginsView.spec.ts index 3fa4a7ed29..2504964aa5 100644 --- a/frontend/src/views/admin/__tests__/PluginsView.spec.ts +++ b/frontend/src/views/admin/__tests__/PluginsView.spec.ts @@ -1,7 +1,11 @@ -import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import PluginsView from '../PluginsView.vue' +import BaseDialog from '@/components/common/BaseDialog.vue' +import type { PluginUISession } from '@/api/admin' + +enableAutoUnmount(afterEach) const { listPlugins, @@ -151,6 +155,60 @@ describe('管理员插件页二次验证', () => { expect(enablePlugin).toHaveBeenCalledWith(7, 100, false) }) + it.each([7, 8])('keeps the latest session when reopening configuration for plugin %s', async (id) => { + let resolveOld!: (session: PluginUISession) => void + createUISession.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + listPlugins.mockResolvedValue([plugin, { ...plugin, id: 8, name: 'Other Transport' }]) + const wrapper = mountView() + await flushPromises() + const buttons = wrapper.findAll('button').filter(item => item.text() === 'admin.plugins.configure') + await buttons[0].trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + await buttons[id === 7 ? 0 : 1].trigger('click') + await flushPromises() + const currentURL = wrapper.get('iframe').attributes('src') + + resolveOld({ url: '/obsolete-session', bridge_token: 'old', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + expect(wrapper.get('iframe').attributes('src')).toBe(currentURL) + }) + + it('ignores an old session error while the reopened configuration is loading', async () => { + let rejectOld!: (error: Error) => void + let resolveCurrent!: (session: PluginUISession) => void + createUISession + .mockReturnValueOnce(new Promise((_, reject) => { rejectOld = reject })) + .mockReturnValueOnce(new Promise((resolve) => { resolveCurrent = resolve })) + const wrapper = mountView() + await flushPromises() + const button = wrapper.findAll('button').find(item => item.text() === 'admin.plugins.configure')! + await button.trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + await button.trigger('click') + rejectOld(new Error('obsolete failure')) + await flushPromises() + + expect(wrapper.text()).not.toContain('obsolete failure') + expect(wrapper.text()).toContain('admin.plugins.loadingUI') + resolveCurrent({ url: '/current-session', bridge_token: 'current', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + await wrapper.get('iframe').trigger('load') + expect(wrapper.get('iframe').attributes('src')).toBe('/current-session') + expect(wrapper.text()).not.toContain('admin.plugins.loadingUI') + }) + + it('does not restore a session after the configuration dialog closes', async () => { + let resolveSession!: (session: PluginUISession) => void + createUISession.mockReturnValueOnce(new Promise((resolve) => { resolveSession = resolve })) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(item => item.text() === 'admin.plugins.configure')!.trigger('click') + wrapper.getComponent(BaseDialog).vm.$emit('close') + resolveSession({ url: '/closed-session', bridge_token: 'old', ui_bridge_version: 1, expires_at: '' }) + await flushPromises() + expect(wrapper.find('iframe').exists()).toBe(false) + }) + it('上传插件通过 step-up 控制器执行', async () => { const wrapper = mountView() await flushPromises()