feat(plugin-host): add combined lifecycle controls (#2476)
## Summary - Add combined `Enable and Load` and `Disable and Unload` controls to Plugin Host Debug. - Keep enablement persistence and runtime lifecycle changes in one sequential store action. - Add tests for ordering, busy state, and failure handling. - Localize the new controls and fallback error messages in English and Simplified Chinese. ## Verification - `pnpm exec vitest run --config packages/stage-ui/vitest.config.ts --project node src/stores/devtools/plugin-host-debug.test.ts` - `pnpm -F @proj-airi/i18n test` - `pnpm -F @proj-airi/i18n build` - `pnpm run build:packages` - `pnpm typecheck` - `pnpm lint` - Manually tested the bundled `devtools-sample-plugin` in the Electron Plugin Host Debug page. Both combined actions updated the persisted registry and runtime session state without restarting the app. ## Visual changes | Before | After | |---|---| |  |  | | Plugin Host Debug | Plugin Host Debug with combined lifecycle controls |
This commit is contained in:
@@ -1641,6 +1641,13 @@ pages:
|
||||
markdown-stress:
|
||||
title: Markdown Stress
|
||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||
plugin-host:
|
||||
actions:
|
||||
enable-and-load: Enable and Load
|
||||
disable-and-unload: Disable and Unload
|
||||
errors:
|
||||
enable-and-load: 'Failed to enable and load plugin {extensionId}.'
|
||||
disable-and-unload: 'Failed to disable and unload plugin {extensionId}.'
|
||||
use-magic-keys:
|
||||
title: useMagicKeys Tool
|
||||
description: Test shortcuts
|
||||
|
||||
@@ -1555,6 +1555,13 @@ pages:
|
||||
markdown-stress:
|
||||
title: Markdown Stress
|
||||
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
||||
plugin-host:
|
||||
actions:
|
||||
enable-and-load: 启用并加载
|
||||
disable-and-unload: 停用并卸载
|
||||
errors:
|
||||
enable-and-load: '启用并加载插件 {extensionId} 失败。'
|
||||
disable-and-unload: '停用并卸载插件 {extensionId} 失败。'
|
||||
use-magic-keys:
|
||||
title: useMagicKeys 工具
|
||||
description: 测试快捷键
|
||||
|
||||
@@ -9,9 +9,11 @@ import { Section } from '@proj-airi/stage-ui/components'
|
||||
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
||||
import { Button, Callout, GhostButton, Input } from '@proj-airi/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const store = usePluginHostInspectorStore()
|
||||
const { t } = useI18n()
|
||||
const filter = ref('')
|
||||
const selectedExtensionId = ref('')
|
||||
|
||||
@@ -150,6 +152,24 @@ async function unloadPlugin(plugin: PluginManifestSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAndLoadPlugin(plugin: PluginManifestSummary) {
|
||||
try {
|
||||
await store.enableAndLoad({ extensionId: plugin.extensionId, path: plugin.path })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.enable-and-load', { extensionId: plugin.extensionId }))
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAndUnloadPlugin(plugin: PluginManifestSummary) {
|
||||
try {
|
||||
await store.disableAndUnload({ extensionId: plugin.extensionId, path: plugin.path })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.disable-and-unload', { extensionId: plugin.extensionId }))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPlugin() {
|
||||
const extensionId = selectedExtensionId.value.trim()
|
||||
if (!extensionId) {
|
||||
@@ -304,6 +324,22 @@ onMounted(async () => {
|
||||
</span>
|
||||
</div>
|
||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||
<Button
|
||||
size="sm"
|
||||
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.enable-and-load')"
|
||||
icon="i-solar:play-bold-duotone"
|
||||
:disabled="store.loading || (plugin.enabled && plugin.loaded)"
|
||||
:loading="store.loading"
|
||||
@click="enableAndLoadPlugin(plugin)"
|
||||
/>
|
||||
<GhostButton
|
||||
size="sm"
|
||||
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.disable-and-unload')"
|
||||
icon="i-solar:stop-bold-duotone"
|
||||
:disabled="store.loading || (!plugin.enabled && !plugin.loaded)"
|
||||
:loading="store.loading"
|
||||
@click="disableAndUnloadPlugin(plugin)"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PluginHostDebugSnapshot, PluginRegistrySnapshot } from './plugin-host-debug'
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePluginHostInspectorStore } from './plugin-host-debug'
|
||||
|
||||
function createRegistry(enabled: boolean, loaded: boolean): PluginRegistrySnapshot {
|
||||
return {
|
||||
root: 'extensions/v1',
|
||||
plugins: [{
|
||||
extensionId: 'sample-plugin',
|
||||
entrypoints: { electron: './index.mjs' },
|
||||
path: 'sample-plugin/extension.airi.json',
|
||||
enabled,
|
||||
loaded,
|
||||
autoReload: false,
|
||||
isNew: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function createBridge(registry: PluginRegistrySnapshot) {
|
||||
const snapshot: PluginHostDebugSnapshot = {
|
||||
registry,
|
||||
sessions: registry.plugins.filter(plugin => plugin.loaded).map(plugin => ({
|
||||
id: 'sample-session',
|
||||
extensionId: plugin.extensionId,
|
||||
phase: 'ready',
|
||||
runtime: 'electron',
|
||||
moduleId: plugin.extensionId,
|
||||
})),
|
||||
kits: [],
|
||||
modules: [],
|
||||
capabilities: [],
|
||||
refreshedAt: 1,
|
||||
}
|
||||
|
||||
return {
|
||||
list: vi.fn(async () => registry),
|
||||
setEnabled: vi.fn(async () => registry),
|
||||
setAutoReload: vi.fn(async () => registry),
|
||||
loadEnabled: vi.fn(async () => registry),
|
||||
load: vi.fn(async () => registry),
|
||||
unload: vi.fn(async () => registry),
|
||||
inspect: vi.fn(async () => snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ action: 'enableAndLoad' as const, runtimeAction: 'load' as const, enabled: true },
|
||||
{ action: 'disableAndUnload' as const, runtimeAction: 'unload' as const, enabled: false },
|
||||
])('$action', ({ action, runtimeAction, enabled }) => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('saves enablement before changing runtime state and stays busy through inspection', async () => {
|
||||
const finalRegistry = createRegistry(enabled, enabled)
|
||||
const bridge = createBridge(finalRegistry)
|
||||
const save = Promise.withResolvers<PluginRegistrySnapshot>()
|
||||
const runtime = Promise.withResolvers<PluginRegistrySnapshot>()
|
||||
const inspection = Promise.withResolvers<void>()
|
||||
const inspect = bridge.inspect.getMockImplementation()!
|
||||
bridge.setEnabled.mockReturnValueOnce(save.promise)
|
||||
bridge[runtimeAction].mockReturnValueOnce(runtime.promise)
|
||||
bridge.inspect.mockImplementationOnce(async () => {
|
||||
await inspection.promise
|
||||
return inspect()
|
||||
})
|
||||
|
||||
const store = usePluginHostInspectorStore()
|
||||
store.setBridge(bridge)
|
||||
const operation = store[action]({
|
||||
extensionId: 'sample-plugin',
|
||||
path: 'sample-plugin/extension.airi.json',
|
||||
})
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
expect(bridge.setEnabled).toHaveBeenCalledExactlyOnceWith({
|
||||
extensionId: 'sample-plugin',
|
||||
path: 'sample-plugin/extension.airi.json',
|
||||
enabled,
|
||||
})
|
||||
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
|
||||
|
||||
const savedRegistry = createRegistry(enabled, !enabled)
|
||||
save.resolve(savedRegistry)
|
||||
await vi.waitFor(() => expect(bridge[runtimeAction]).toHaveBeenCalledExactlyOnceWith({ extensionId: 'sample-plugin' }))
|
||||
expect(store.registry).toEqual(savedRegistry)
|
||||
expect(store.loading).toBe(true)
|
||||
expect(bridge.inspect).not.toHaveBeenCalled()
|
||||
|
||||
runtime.resolve(finalRegistry)
|
||||
await vi.waitFor(() => expect(bridge.inspect).toHaveBeenCalledOnce())
|
||||
expect(store.registry).toEqual(finalRegistry)
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
inspection.resolve()
|
||||
await expect(operation).resolves.toEqual(finalRegistry)
|
||||
expect(store.sessions).toHaveLength(enabled ? 1 : 0)
|
||||
expect(store.refreshedAt).toBe(1)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not change runtime state when saving enablement fails', async () => {
|
||||
const bridge = createBridge(createRegistry(!enabled, !enabled))
|
||||
bridge.setEnabled.mockRejectedValueOnce(new Error('Could not save enablement.'))
|
||||
const store = usePluginHostInspectorStore()
|
||||
store.setBridge(bridge)
|
||||
|
||||
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Could not save enablement.')
|
||||
|
||||
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
|
||||
expect(bridge.inspect).not.toHaveBeenCalled()
|
||||
expect(store.error).toBe('Could not save enablement.')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the saved enablement visible when the runtime operation fails', async () => {
|
||||
const savedRegistry = createRegistry(enabled, !enabled)
|
||||
const bridge = createBridge(savedRegistry)
|
||||
bridge[runtimeAction].mockRejectedValueOnce(new Error('Runtime operation failed.'))
|
||||
const store = usePluginHostInspectorStore()
|
||||
store.setBridge(bridge)
|
||||
|
||||
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Runtime operation failed.')
|
||||
|
||||
expect(bridge.setEnabled).toHaveBeenCalledOnce()
|
||||
expect(store.registry).toEqual(savedRegistry)
|
||||
expect(store.error).toBe('Runtime operation failed.')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -206,6 +206,28 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
|
||||
return nextRegistry
|
||||
}
|
||||
|
||||
/** Keeps enablement visible if loading fails, so the user can retry loading. */
|
||||
async function enableAndLoad(payload: { extensionId: string, path?: string }) {
|
||||
return withBridge(async (activeBridge) => {
|
||||
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: true }))
|
||||
const nextRegistry = await activeBridge.load({ extensionId: payload.extensionId })
|
||||
assignRegistry(nextRegistry)
|
||||
assignInspection(await activeBridge.inspect())
|
||||
return nextRegistry
|
||||
})
|
||||
}
|
||||
|
||||
/** Keeps the plugin disabled for future startup even if stopping its current session fails. */
|
||||
async function disableAndUnload(payload: { extensionId: string, path?: string }) {
|
||||
return withBridge(async (activeBridge) => {
|
||||
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: false }))
|
||||
const nextRegistry = await activeBridge.unload({ extensionId: payload.extensionId })
|
||||
assignRegistry(nextRegistry)
|
||||
assignInspection(await activeBridge.inspect())
|
||||
return nextRegistry
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
registry,
|
||||
sessions,
|
||||
@@ -229,5 +251,7 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
|
||||
loadEnabled,
|
||||
load,
|
||||
unload,
|
||||
enableAndLoad,
|
||||
disableAndUnload,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user