Files
MoviePilot/tests/test_plugin_sync_service.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

825 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
插件市场同步服务用例。
"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from packaging.version import Version
from app.application.plugin.catalog import PluginCatalogService
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.gateway import PluginInstallGateway
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.install import PluginInstallResult
from app.application.plugin.lifecycle import plugin_lifecycle
from app.application.plugin.source import (
CandidateInventory,
LocalCandidateRead,
MarketRead,
PluginLocalCandidate,
PluginMarketCandidate,
)
from app.runtime.config import global_vars
from app.runtime.extensions.plugin.sync import LocalPluginSyncService, PluginSyncService
from app.schemas.plugin import PluginRuntimeStatus
from app.startup.initializers import plugins as plugins_initializer
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
def _merge_catalog_plugins(higher, base, markets):
"""
通过生产目录服务合并启动同步候选。
"""
service = PluginCatalogService(
market_loader=Mock(return_value={}),
async_market_loader=AsyncMock(return_value={}),
installed_plugins_provider=Mock(return_value=[]),
plugin_mapper=Mock(),
is_local_repo=lambda value: str(value).startswith("local://"),
version_compare=lambda left, operator, right: (
operator == ">" and Version(left) > Version(right)
),
warning=Mock(),
error=Mock(),
)
return service.merge(higher, base, markets)
def test_market_sync_keeps_install_rollback_enabled() -> None:
"""
自动更新插件时保留旧版本,失败后可由安装器恢复。
"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url="https://example.com/plugins",
plugin_name="Demo",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
assert service.sync() == [plugin.id]
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None:
"""
在线候选先按版本仲裁,不能因输入顺序选择旧版本。
"""
old = SimpleNamespace(
id="DemoPlugin",
repo_url="https://example.com/old",
plugin_name="Demo old",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
new = SimpleNamespace(
id="DemoPlugin",
repo_url="https://example.com/new",
plugin_name="Demo new",
plugin_version="2.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
install = Mock(return_value=(True, ""))
def merge_online(items, *_args):
return [max(items, key=lambda item: Version(item.plugin_version))]
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [old.id],
online_plugins=lambda: [new, old],
local_plugins=lambda: [],
merge_plugins=merge_online,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
assert service.sync() == [new.id]
install.assert_called_once_with(new.id, None, False, None)
def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None:
"""
本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。
"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=False,
runtime_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(
online_restore_plugins={"demoplugin"},
) == [plugin.id]
install.assert_called_once_with(plugin.id, None, False, None)
def test_market_sync_excludes_runtime_incompatible_candidates() -> None:
"""
运行时声明不兼容的插件不得进入启动同步。
这类插件装不上:安装准入必然拒绝,同步失败状态会覆盖加载器写下的不兼容状态,
恢复集合还会把整个启动同步判为未完成,连带跳过依赖恢复与调度器初始化。
"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=False,
)
install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)"))
status_writer = Mock()
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
# 运行目录缺失是这类插件在 v3t 上的常态:加载器跳过后它不在运行表里
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
runtime_status_writer=status_writer,
)
assert service.sync() == []
install.assert_not_called()
# 不能用同步失败状态覆盖加载器写下的 INCOMPATIBLE_RUNTIME
status_writer.assert_not_called()
def test_market_sync_excludes_runtime_incompatible_restore_targets() -> None:
"""
恢复分支同样排除:重装当前运行时装不上的插件不会有别的结果。
"""
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=False,
)
install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)"))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
# 恢复失败会抛 RuntimeError 中断启动同步,这里必须连候选都不产生
assert service.sync(online_restore_plugins={"demoplugin"}) == []
install.assert_not_called()
def test_market_sync_skips_existing_local_candidate() -> None:
"""
运行目录已有可用插件时,启动同步不因本地候选重复安装。
"""
online = SimpleNamespace(
id="DemoPlugin",
repo_url=REPO_URL,
plugin_name="Demo",
plugin_version="1.2.0",
system_version_compatible=True,
runtime_compatible=True,
)
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?package_version=v3",
plugin_name="Demo Local",
plugin_version="9.9.10",
system_version_compatible=True,
runtime_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [online.id],
online_plugins=lambda: [online],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: [online],
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
assert service.sync(online_restore_plugins={"demoplugin"}) == []
install.assert_not_called()
def test_market_sync_defers_source_selection_to_gateway() -> None:
"""
启动目录只能决定同步目标,不能把本地候选升级为选源授权。
"""
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
plugin_name="Demo Local",
plugin_version="3.3.2",
system_version_compatible=True,
runtime_compatible=True,
)
install = Mock(return_value=(True, ""))
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [local.id],
online_plugins=lambda: [],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
assert service.sync() == [local.id]
install.assert_called_once_with(local.id, local.repo_url, False, None)
def test_market_sync_isolates_local_install_failure() -> None:
"""
本地载荷恢复失败保留该插件状态,但不阻止其余启动编排。
"""
local = SimpleNamespace(
id="DemoPlugin",
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
plugin_name="Demo Local",
plugin_version="3.3.2",
system_version_compatible=True,
runtime_compatible=True,
)
statuses: list[tuple[str, PluginRuntimeStatus]] = []
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [local.id],
online_plugins=lambda: [],
local_plugins=lambda: [local],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=Mock(return_value=(False, "copy failed")),
runtime_status_writer=lambda plugin_id, status: statuses.append(
(plugin_id, status)
),
log=Mock(),
)
assert service.sync() == []
assert statuses == [("DemoPlugin", PluginRuntimeStatus.SYNC_FAILED)]
def test_market_sync_continues_after_one_plugin_install_failure() -> None:
"""
一个恢复目标失败时仍完成其它插件的安装并返回其成功清单。
"""
failed = SimpleNamespace(
id="FailedPlugin",
repo_url="local://FailedPlugin?version=v3",
plugin_name="Failed",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
succeeded = SimpleNamespace(
id="GoodPlugin",
repo_url="local://GoodPlugin?version=v3",
plugin_name="Good",
plugin_version="1.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
install = Mock(
side_effect=lambda plugin_id, *_args: (
(False, "copy failed") if plugin_id == failed.id else (True, "")
)
)
statuses: list[tuple[str, PluginRuntimeStatus]] = []
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [failed.id, succeeded.id],
online_plugins=lambda: [],
local_plugins=lambda: [failed, succeeded],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=install,
runtime_status_writer=lambda plugin_id, status: statuses.append(
(plugin_id, status)
),
log=Mock(),
)
assert set(service.sync()) == {succeeded.id}
assert {call.args[0] for call in install.call_args_list} == {
failed.id,
succeeded.id,
}
assert statuses == [(failed.id, PluginRuntimeStatus.SYNC_FAILED)]
def test_market_sync_stops_without_online_fallback_when_local_scan_fails() -> None:
"""
本地仓扫描失败时,启动同步不得退回在线候选。
"""
statuses: list[tuple[str, object]] = []
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: ["DemoPlugin"],
online_plugins=lambda: pytest.fail("本地扫描失败后不应读取在线候选"),
local_plugins=lambda: (_ for _ in ()).throw(RuntimeError("repo unavailable")),
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: False,
install=Mock(return_value=(True, "")),
runtime_status_writer=lambda plugin_id, status: statuses.append((plugin_id, status)),
log=Mock(),
)
with pytest.raises(RuntimeError, match="本地插件仓读取失败"):
service.sync()
assert statuses == [("demoplugin", PluginRuntimeStatus.SYNC_FAILED)]
def test_local_sync_matches_installed_plugin_id_case_insensitively() -> None:
"""
本地热同步应把大小写不同的索引 ID 识别为同一已安装插件。
"""
candidate = {
"repo_url": "local://downloadcenter?package_version=v3",
"package_version": "v3",
"compatible": True,
}
system = Mock()
system.install_plugin.return_value = (True, "")
recent_sync: dict[str, float] = {}
service = LocalPluginSyncService(
installed_plugins=lambda: ["DownloadCenter"],
candidate=Mock(return_value=candidate),
system=lambda: system,
recent_sync=recent_sync,
log=Mock(),
)
assert service.sync("DownloadCenter", candidate)
system.install_plugin.assert_called_once_with(
plugin_id="DownloadCenter",
repo_url=candidate["repo_url"],
package_version="v3",
force=True,
local_sync=True,
explicit_source=True,
)
assert "downloadcenter" in recent_sync
@pytest.mark.asyncio
async def test_market_sync_preserves_generation_priority_through_gateway(
monkeypatch,
) -> None:
"""
目录中的高版本 V2 不能绕过 Gateway 覆盖绑定仓库的 V3。
"""
online = PluginMarketCandidate(
plugin_id="DownloadCenter",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id="downloadcenter",
repo_url="local://downloadcenter?path=/private/plugins&version=v2",
package_generation="v2",
plugin_version="9.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id="DownloadCenter",
normalized_plugin_id="downloadcenter",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=online.source_key,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=online.source_key,
declared_version=online.plugin_version,
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version=online.plugin_version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "2" * 64,
revision=2,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
inventory = CandidateInventory(
(MarketRead.present(REPO_URL, (online,)),),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
executor = AsyncMock()
executor.execute.return_value = PluginInstallResult(success=True)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
source_plugin_id=lambda plugin_id: plugin_id,
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
"""
按生产兼容入口让 Gateway 完成最终来源准入。
"""
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url or "",
package_version="v3",
release_version=None,
force=force,
local_sync=True,
explicit_source=True,
startup_token=startup_token,
)
merged_local = SimpleNamespace(
id=local.plugin_id,
repo_url=local.repo_url,
plugin_name="Demo Local",
plugin_version=local.plugin_version,
system_version_compatible=True,
runtime_compatible=True,
)
merged_online = SimpleNamespace(
id=online.plugin_id,
repo_url=online.repo_url,
plugin_name="Download Center",
plugin_version=online.plugin_version,
system_version_compatible=True,
runtime_compatible=True,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [online.plugin_id],
online_plugins=lambda: [merged_online],
local_plugins=lambda: [merged_local],
merge_plugins=_merge_catalog_plugins,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
synced = await asyncio.wait_for(
asyncio.to_thread(service.sync, startup_token),
timeout=2,
)
assert synced == [local.plugin_id]
executor.execute.assert_awaited_once()
assert executor.execute.await_args.kwargs["local_sync"] is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate is local
assert admission.trusted_source_key == online.source_key
@pytest.mark.asyncio
async def test_market_sync_blocks_activation_when_gateway_selected_local_fails(
monkeypatch,
) -> None:
"""
目录预选在线候选时,Gateway 改选本地失败仍必须阻止旧载荷激活。
"""
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
official = PluginMarketCandidate(
plugin_id="DemoPlugin",
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.0.0",
dto={"v3": True},
)
competing = PluginMarketCandidate(
plugin_id=official.plugin_id,
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url=competing_repo_url,
package_generation="v3",
plugin_version="9.0.0",
dto={"v3": True},
)
local = PluginLocalCandidate(
plugin_id=official.plugin_id,
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
package_generation="v3",
plugin_version="2.0.0",
dto={"v3": True},
)
identity = PluginIdentity(
plugin_id=official.plugin_id,
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key=official.source_key,
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.OFFICIAL,
payload_source_key=official.source_key,
declared_version=official.plugin_version,
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo", "v3": True},
declaration_version=official.plugin_version,
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "3" * 64,
revision=2,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
inventory = CandidateInventory(
(
MarketRead.present(REPO_URL, (official,)),
MarketRead.present(competing_repo_url, (competing,)),
),
(local,),
local_read=LocalCandidateRead.present((local,)),
)
executor = AsyncMock(
**{"execute.return_value": PluginInstallResult(
success=False,
message="copy failed",
)}
)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
source_plugin_id=lambda plugin_id: plugin_id,
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url or "",
package_version="v3",
release_version=None,
force=force,
local_sync=False,
explicit_source=False,
startup_token=startup_token,
)
merged_official = SimpleNamespace(
id=official.plugin_id,
repo_url=official.repo_url,
plugin_name="Demo Official",
plugin_version=official.plugin_version,
system_version_compatible=True,
runtime_compatible=True,
)
merged_competing = SimpleNamespace(
id=competing.plugin_id,
repo_url=competing.repo_url,
plugin_name="Demo Competing",
plugin_version=competing.plugin_version,
system_version_compatible=True,
runtime_compatible=True,
)
merged_local = SimpleNamespace(
id=local.plugin_id,
repo_url=local.repo_url,
plugin_name="Demo Local",
plugin_version=local.plugin_version,
system_version_compatible=True,
runtime_compatible=True,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [official.plugin_id],
online_plugins=lambda: [merged_official, merged_competing],
local_plugins=lambda: [merged_local],
merge_plugins=_merge_catalog_plugins,
plugin_exists=lambda *_args: False,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
result = await asyncio.wait_for(
asyncio.to_thread(service.sync, startup_token),
timeout=2,
)
assert result == []
executor.execute.assert_awaited_once()
assert executor.execute.await_args.kwargs["local_sync"] is True
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate is local
@pytest.mark.asyncio
async def test_market_sync_reuses_startup_lease_through_real_gateway(
monkeypatch,
) -> None:
"""
启动自动安装跨线程进入 Gateway 时必须复用同一个 startup lease。
"""
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
plugin = SimpleNamespace(
id="DemoPlugin",
repo_url=competing_repo_url,
plugin_name="Demo",
plugin_version="9.0.0",
system_version_compatible=True,
runtime_compatible=True,
)
official_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:jxxghp/moviepilot-plugins",
source_type=TrustedPluginSourceType.OFFICIAL,
repo_url=REPO_URL,
package_generation="v3",
plugin_version="1.1.0",
dto={"v3": True},
)
competing_candidate = PluginMarketCandidate(
plugin_id=plugin.id,
source_key="github:example/moviepilot-plugins",
source_type=TrustedPluginSourceType.THIRD_PARTY,
repo_url=competing_repo_url,
package_generation="v3",
plugin_version=plugin.plugin_version,
dto={"v3": True},
)
inventory = CandidateInventory((
MarketRead.present(
REPO_URL,
(official_candidate,),
package_generation="v3",
),
MarketRead.present(
competing_repo_url,
(competing_candidate,),
package_generation="v3",
),
))
identity = PluginIdentity(
plugin_id=plugin.id,
normalized_plugin_id="demoplugin",
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
payload_source_type=PluginPayloadSourceType.LOCAL,
payload_source_key=None,
declared_version="9.9.10",
package_generation="v3",
declared_metadata=PluginDeclaredMetadata.from_package(
{"name": "Demo local", "v3": True},
declaration_version="9.9.10",
manifest_matches_payload=True,
),
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
)
executor = AsyncMock()
executor.execute.return_value = PluginInstallResult(success=True)
gateway = PluginInstallGateway(
inventory=AsyncMock(return_value=inventory),
identity=AsyncMock(return_value=identity),
candidate_compatibility=lambda _candidate: (True, ""),
executor=executor,
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
source_plugin_id=lambda plugin_id: plugin_id,
)
monkeypatch.setattr(
global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
def install(
plugin_id: str,
repo_url: str | None,
force: bool,
startup_token: object | None,
) -> tuple[bool, str]:
"""
复用生产同步包装层,把线程池安装提交回宿主事件循环。
"""
return plugins_initializer._run_plugin_install_sync(
gateway,
plugin_id=plugin_id,
repo_url=repo_url,
package_version="v3",
release_version=None,
force=force,
local_sync=False,
explicit_source=False,
startup_token=startup_token,
)
service = PluginSyncService(
frozen=lambda: False,
installed_plugins=lambda: [plugin.id],
online_plugins=lambda: [plugin],
local_plugins=lambda: [],
merge_plugins=lambda items, *_args: items,
plugin_exists=lambda *_args: True,
install=install,
log=Mock(),
)
async with plugin_lifecycle.hold_startup() as startup_token:
synced = await asyncio.wait_for(
asyncio.to_thread(
service.sync,
startup_token,
online_restore_plugins={"demoplugin"},
),
timeout=2,
)
assert synced == [plugin.id]
executor.execute.assert_awaited_once()
admission = executor.execute.await_args.kwargs["admission"]
assert admission.candidate.repo_url == REPO_URL