feat(plugin): sync federated assets during local development (#6100)

This commit is contained in:
InfinityPacer
2026-07-11 21:59:53 +08:00
committed by GitHub
parent 13f27854fd
commit 54be1143fc
2 changed files with 479 additions and 4 deletions

View File

@@ -363,6 +363,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
logger.warn(f"检测到本地插件 {candidate.get('id')} 依赖文件变化,请重新安装本地插件以安装依赖")
continue
federated_change = self._get_federated_plugin_change(event_path)
if federated_change:
pid, candidate, remote_entry_ready = federated_change
# 运行目录由构建方直接写入;外部本地仓库只在入口完整时同步运行副本。
if candidate and remote_entry_ready:
if candidate.get("compatible") is False:
logger.info(
f"检测到本地插件 {pid} 联邦构建产物变化,"
f"但跳过同步:{candidate.get('skip_reason')}"
)
elif pid not in local_plugins_to_sync:
local_plugins_to_sync[pid] = (candidate, event_path, False)
continue
# 跳过非 .py 文件
if not event_path.name.endswith(".py"):
continue
@@ -385,13 +399,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
f"文件:{event_path},但跳过同步:{local_candidate.get('skip_reason')}"
)
continue
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path)
local_plugins_to_sync[local_candidate.get("id")] = (local_candidate, event_path, True)
for pid, (candidate, event_path) in local_plugins_to_sync.items():
for pid, (candidate, event_path, should_reload) in local_plugins_to_sync.items():
package_version = candidate.get("package_version")
source_root = f"plugins.{package_version}" if package_version else "plugins"
logger.info(f"检测到本地插件 {pid} 文件变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate):
change_name = "Python 文件" if should_reload else "联邦构建产物"
logger.info(f"检测到本地插件 {pid} {change_name}变化,来源:{source_root},文件:{event_path}")
if self._sync_local_plugin_if_installed(pid, candidate) and should_reload:
plugins_to_reload.add(pid)
# 触发重载
@@ -403,6 +418,71 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
except Exception as e:
logger.error(f"插件 {pid} 热重载失败: {e}", exc_info=True)
def _get_federated_plugin_change(
self,
event_path: Path,
) -> Optional[Tuple[str, Optional[dict], bool]]:
"""
识别运行态 Vue 插件声明目录内的构建产物变化。
:return: 插件 ID、本地仓库候选和联邦入口是否完整非联邦目录变化返回 None。
"""
try:
event_path = event_path.resolve()
candidate = self._get_local_plugin_candidate_from_path(event_path)
if candidate:
pid = candidate.get("id")
plugin_dir = Path(candidate.get("path")).resolve()
else:
runtime_root = (settings.ROOT_PATH / "app" / "plugins").resolve()
if not event_path.is_relative_to(runtime_root):
return None
relative_parts = event_path.relative_to(runtime_root).parts
if not relative_parts:
return None
plugin_dir = runtime_root / relative_parts[0]
pid = next(
(
plugin_id
for plugin_id in self._running_plugins
if plugin_id.lower() == relative_parts[0].lower()
),
None,
)
if not pid:
return None
plugin = self._running_plugins.get(pid)
if not plugin:
return None
render_mode, dist_path = plugin.get_render_mode()
if render_mode != "vue" or not isinstance(dist_path, str) or not dist_path:
return None
relative_dist_path = Path(dist_path)
if relative_dist_path.is_absolute() or ".." in relative_dist_path.parts or "\\" in dist_path:
return None
plugin_dir = plugin_dir.resolve()
dist_dir = (plugin_dir / relative_dist_path).resolve()
if (
dist_dir == plugin_dir
or not dist_dir.is_relative_to(plugin_dir)
or not event_path.is_relative_to(dist_dir)
):
return None
remote_entry = dist_dir / "remoteEntry.js"
remote_entry_ready = (
remote_entry.is_file()
and remote_entry.resolve().is_relative_to(plugin_dir)
)
return pid, candidate, remote_entry_ready
except Exception as e:
logger.error(f"识别插件联邦构建产物变化时出错: {e}")
return None
@staticmethod
def _get_plugin_id_from_path(event_path: Path) -> Optional[str]:
"""

View File

@@ -1,9 +1,11 @@
from pathlib import Path
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import Mock
import pytest
from packaging.version import Version
from watchfiles import Change
from app.core.plugin import PluginManager
from app.helper.plugin import PluginHelper
@@ -45,6 +47,38 @@ def _build_local_plugin_repo(tmp_path: Path) -> tuple[Path, Path]:
return repo_path, source_file
def _configure_local_watcher(
monkeypatch,
tmp_path: Path,
repo_path: Path,
changes: set[tuple[Change, str]],
*,
dev: bool = True,
) -> None:
"""为单批次本地插件文件监测提供完整运行配置。"""
settings_stub = SimpleNamespace(
DEV=dev,
PLUGIN_AUTO_RELOAD=True,
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
ROOT_PATH=tmp_path,
VERSION_FLAG="v2",
)
monkeypatch.setattr("app.core.plugin.settings", settings_stub)
monkeypatch.setattr("app.helper.plugin.settings", settings_stub)
monkeypatch.setattr("app.core.plugin.watch", lambda *_args, **_kwargs: iter([changes]))
def _set_running_render_mode(
plugin_manager: PluginManager,
render_mode: str,
dist_path: str,
) -> None:
"""注册测试所需的运行态插件联邦渲染声明。"""
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(
get_render_mode=lambda: (render_mode, dist_path),
)
def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_lags(
tmp_path,
monkeypatch,
@@ -116,3 +150,364 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate(
assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin")
assert not runtime_dir.exists()
def test_local_federated_asset_batch_syncs_once_without_python_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""同批联邦资产变化只同步一次运行副本,不触发 Python 热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
chunk_file = source_dir / "dist" / "assets" / "chunk.js"
chunk_file.write_text("export const chunk = true\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{
(Change.modified, str(source_dir / "dist" / "assets" / "remoteEntry.js")),
(Change.modified, str(chunk_file)),
},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
monkeypatch.setattr(
"app.core.plugin.SystemConfigOper.get",
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
)
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
assert sync_spy.call_args.args[0] == "DemoPlugin"
assert (tmp_path / "app" / "plugins" / "demoplugin" / "dist" / "assets" / "chunk.js").is_file()
reload_spy.assert_not_called()
@pytest.mark.parametrize(
("render_mode", "dist_path"),
[
("schema", "dist/assets"),
("vue", "../assets"),
("vue", "dist/../assets"),
("vue", "dist\\assets"),
("vue", "/tmp/assets"),
],
)
def test_local_federated_asset_ignores_non_vue_or_unsafe_render_paths(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
render_mode: str,
dist_path: str,
) -> None:
"""非 Vue 模式和越出插件目录的声明路径不参与本地同步。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(remote_entry))},
)
_set_running_render_mode(plugin_manager, render_mode, dist_path)
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_ignores_change_outside_declared_directory(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""声明目录外的普通文件变化不复制联邦构建产物。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file.parent / "README.md"))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_requires_remote_entry(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""联邦入口文件不存在时不复制声明目录中的其它构建产物。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
remote_entry = source_dir / "dist" / "assets" / "remoteEntry.js"
remote_entry.unlink()
chunk_file = source_dir / "dist" / "assets" / "chunk.js"
chunk_file.write_text("export const chunk = true\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(chunk_file))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_federated_asset_reads_running_render_mode_for_each_batch(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""每批变化都从运行实例读取当前联邦目录,不缓存旧声明。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
source_dir = source_file.parent
next_entry = source_dir / "next" / "assets" / "remoteEntry.js"
next_entry.parent.mkdir(parents=True)
next_entry.write_text("export default {}\n", encoding="utf-8")
settings_stub = SimpleNamespace(
DEV=True,
PLUGIN_AUTO_RELOAD=True,
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
ROOT_PATH=tmp_path,
VERSION_FLAG="v2",
)
monkeypatch.setattr("app.core.plugin.settings", settings_stub)
monkeypatch.setattr("app.helper.plugin.settings", settings_stub)
monkeypatch.setattr(
"app.core.plugin.watch",
lambda *_args, **_kwargs: iter([
{(Change.modified, str(source_dir / "dist" / "assets" / "remoteEntry.js"))},
{(Change.modified, str(next_entry))},
]),
)
render_mode = Mock(side_effect=[("vue", "dist/assets"), ("vue", "next/assets")])
plugin_manager.running_plugins["DemoPlugin"] = SimpleNamespace(get_render_mode=render_mode)
sync_spy = Mock(return_value=True)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert render_mode.call_count == 2
assert sync_spy.call_count == 2
reload_spy.assert_not_called()
def test_local_federated_asset_respects_non_dev_compatibility_gate(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""非 DEV 自动监测不绕过本地插件的系统版本兼容性门禁。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(remote_entry))},
dev=False,
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_runtime_federated_asset_change_does_not_copy_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""运行目录中的联邦资产由构建方直接写入,不执行本地仓库复制或 Python 重载。"""
repo_path, _source_file = _build_local_plugin_repo(tmp_path)
runtime_dir = tmp_path / "app" / "plugins" / "demoplugin"
runtime_entry = runtime_dir / "dist" / "assets" / "remoteEntry.js"
runtime_entry.parent.mkdir(parents=True)
runtime_entry.write_text("export default {}\n", encoding="utf-8")
(runtime_dir / "__init__.py").write_text(
"from app.plugins import _PluginBase\n"
"class DemoPlugin(_PluginBase):\n"
" plugin_name = 'Demo'\n",
encoding="utf-8",
)
generated_python = runtime_entry.parent / "generated.py"
generated_python.write_text("ASSET = True\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(generated_python))},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_requirements_change_still_does_not_sync_or_reload(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""依赖文件变化继续只提示重新安装,不触发自动同步或热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
requirements_file = source_file.parent / "requirements.txt"
requirements_file.write_text("example==1.0.0\n", encoding="utf-8")
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(requirements_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
sync_spy = Mock()
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
sync_spy.assert_not_called()
reload_spy.assert_not_called()
def test_local_python_change_still_syncs_and_reloads_plugin(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""本地 Python 源码变化继续沿用同步后热重载语义。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file))},
)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
monkeypatch.setattr(
"app.core.plugin.SystemConfigOper.get",
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
)
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
@pytest.mark.parametrize("dist_path", [".", "./"])
def test_local_python_change_rejects_root_federated_path_and_still_reloads(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
dist_path: str,
) -> None:
"""插件根目录不能作为联邦输出目录Python 变化仍需同步并热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{(Change.modified, str(source_file))},
)
_set_running_render_mode(plugin_manager, "vue", dist_path)
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
monkeypatch.setattr(
"app.core.plugin.SystemConfigOper.get",
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
)
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
assert plugin_manager._get_federated_plugin_change(source_file) is None
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")
def test_local_python_and_federated_changes_share_one_batch_sync(
tmp_path,
monkeypatch,
plugin_manager: PluginManager,
) -> None:
"""同批 Python 与联邦资产变化共用一次复制,并保留 Python 热重载。"""
repo_path, source_file = _build_local_plugin_repo(tmp_path)
remote_entry = source_file.parent / "dist" / "assets" / "remoteEntry.js"
_configure_local_watcher(
monkeypatch,
tmp_path,
repo_path,
{
(Change.modified, str(source_file)),
(Change.modified, str(remote_entry)),
},
)
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
monkeypatch.setattr(
"app.core.plugin.SystemConfigOper.get",
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
)
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
reload_spy = Mock()
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
monkeypatch.setattr(plugin_manager, "reload_plugin", reload_spy)
plugin_manager._run_file_watcher()
assert sync_spy.call_count == 1
reload_spy.assert_called_once_with("DemoPlugin")