From 54be1143fccc9c7ef37eb7badd5916c9ada91419 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:59:53 +0800 Subject: [PATCH] feat(plugin): sync federated assets during local development (#6100) --- app/core/plugin.py | 88 ++++++- tests/test_plugin_local_sync.py | 395 ++++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+), 4 deletions(-) diff --git a/app/core/plugin.py b/app/core/plugin.py index 9d6b4863..d55765c3 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -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]: """ diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 90319b4b..69372ae2 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -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")