mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-15 09:22:37 +08:00
feat(plugin): sync federated assets during local development (#6100)
This commit is contained in:
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user