mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 13:06:46 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
495 lines
21 KiB
Python
495 lines
21 KiB
Python
import hashlib
|
|
import json
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from cli.runtime.plugin.sdk import HASH_ALGORITHM, SDK_FILES, SDK_METADATA, SDK_SOURCE, VENDOR_FILES, PluginBridgeSdk
|
|
|
|
|
|
def write_source(
|
|
frontend: Path, *, version: str = '1.0.0', wire: int = 1, capabilities: dict[str, int] | None = None
|
|
) -> PluginBridgeSdk:
|
|
"""
|
|
写入不执行 JavaScript 的最小宿主 SDK 测试源。
|
|
|
|
:param frontend: 临时宿主前端目录
|
|
:param version: SDK 版本
|
|
:param wire: 通信协议版本
|
|
:param capabilities: 声明的可选能力版本
|
|
:return: 指向测试源的 SDK 管理器
|
|
"""
|
|
directory = frontend / 'src' / 'utils'
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / SDK_FILES[0]).write_text(
|
|
f"export const PLUGIN_BRIDGE_SDK_VERSION = '{version}'\nexport const PLUGIN_BRIDGE_VERSION = {wire}\n",
|
|
encoding='utf-8',
|
|
)
|
|
(directory / SDK_FILES[1]).write_text(
|
|
f"export const PLUGIN_BRIDGE_SDK_VERSION: '{version}'\nexport const PLUGIN_BRIDGE_VERSION: {wire}\n",
|
|
encoding='utf-8',
|
|
)
|
|
metadata = {
|
|
'schemaVersion': 1,
|
|
'sdkVersion': version,
|
|
'bridgeVersion': wire,
|
|
'capabilities': capabilities if capabilities is not None else {'files': 1, 'streams': 1},
|
|
}
|
|
(directory / SDK_METADATA).write_text(json.dumps(metadata), encoding='utf-8')
|
|
return PluginBridgeSdk(frontend)
|
|
|
|
|
|
def write_project(project: Path, sdk: PluginBridgeSdk) -> Path:
|
|
"""
|
|
创建含业务文件和 SDK 副本的独立 bundle 工程。
|
|
|
|
:param project: 临时工程目录
|
|
:param sdk: 用于生成初始副本的宿主 SDK
|
|
:return: 创建后的工程目录
|
|
"""
|
|
vendor = project / 'web' / 'vendor'
|
|
vendor.mkdir(parents=True)
|
|
manifest = {
|
|
'manifestVersion': 2,
|
|
'id': 'sdk_demo',
|
|
'name': 'SDK Demo',
|
|
'version': '1.0.0',
|
|
'backend': {
|
|
'module': 'plugins.sdk_demo',
|
|
'entrypoint': 'plugins.sdk_demo:create_plugin',
|
|
'integration': 'asgi',
|
|
},
|
|
'frontend': {'delivery': {'type': 'bundle'}, 'bundle': {'directory': 'web/dist', 'entry': 'index.html'}},
|
|
}
|
|
(project / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8')
|
|
(project / 'web' / 'business.js').write_text('user-business-code\n', encoding='utf-8')
|
|
for name, content in sdk.build_vendor_files().items():
|
|
(vendor / name).write_bytes(content.encode('utf-8'))
|
|
return project
|
|
|
|
|
|
def snapshot(project: Path) -> dict[str, bytes]:
|
|
"""
|
|
收集测试工程全部文件以确认预演和拒绝操作没有副作用。
|
|
|
|
:param project: 临时工程目录
|
|
:return: 相对文件名与原始字节的映射
|
|
"""
|
|
return {str(path.relative_to(project)): path.read_bytes() for path in project.rglob('*') if path.is_file()}
|
|
|
|
|
|
@pytest.fixture
|
|
def outdated(tmp_path: Path) -> tuple[PluginBridgeSdk, Path]:
|
|
"""
|
|
创建完整且版本较旧的 SDK 副本。
|
|
|
|
:param tmp_path: pytest 的独立临时目录
|
|
:return: 当前宿主 SDK 及包含旧副本的工程
|
|
"""
|
|
frontend = tmp_path / 'frontend'
|
|
old = write_source(frontend, version='0.9.0', capabilities={'files': 1})
|
|
project = write_project(tmp_path / 'project', old)
|
|
return write_source(frontend), project
|
|
|
|
|
|
def test_build_vendor_records_normalized_hashes_and_versions(tmp_path: Path) -> None:
|
|
"""SDK 来源记录固定且可复现,CRLF 与 BOM 不改变内容摘要。"""
|
|
frontend = tmp_path / 'frontend'
|
|
sdk = write_source(frontend)
|
|
expected = sdk.build_vendor_files()
|
|
for name in VENDOR_FILES:
|
|
path = frontend / 'src' / 'utils' / name
|
|
text = path.read_text(encoding='utf-8')
|
|
path.write_bytes(b'\xef\xbb\xbf' + text.replace('\n', '\r\n').encode('utf-8'))
|
|
actual = sdk.build_vendor_files()
|
|
assert actual == expected
|
|
assert set(actual) == set(VENDOR_FILES)
|
|
metadata = json.loads(actual[SDK_METADATA])
|
|
assert metadata['hashAlgorithm'] == HASH_ALGORITHM
|
|
assert metadata['source'] == SDK_SOURCE
|
|
assert metadata['sdkVersion'] == '1.0.0'
|
|
assert metadata['files'] == {name: hashlib.sha256(actual[name].encode('utf-8')).hexdigest() for name in SDK_FILES}
|
|
project = write_project(tmp_path / 'project', sdk)
|
|
for name in VENDOR_FILES:
|
|
path = project / 'web' / 'vendor' / name
|
|
path.write_bytes(path.read_bytes().replace(b'\n', b'\r\n'))
|
|
assert sdk.check(project)['status'] == 'current'
|
|
|
|
|
|
@pytest.mark.parametrize('target', ['pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json'])
|
|
def test_invalid_source_version_cannot_be_used(tmp_path: Path, target: str) -> None:
|
|
"""宿主源三份版本声明必须一致,强制模式不能绕过源错误。"""
|
|
frontend = tmp_path / 'frontend'
|
|
sdk = write_source(frontend)
|
|
project = write_project(tmp_path / 'project', sdk)
|
|
before = snapshot(project)
|
|
path = frontend / 'src' / 'utils' / target
|
|
path.write_text(path.read_text(encoding='utf-8').replace('1.0.0', '9.0.0'), encoding='utf-8')
|
|
with pytest.raises(ValueError, match='版本声明'):
|
|
sdk.build_vendor_files()
|
|
assert sdk.check(project)['status'] == 'invalid'
|
|
assert not sdk.update(project, force=True)['ok']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('field', 'value'),
|
|
[('schemaVersion', True), ('bridgeVersion', True), ('sdkVersion', 'latest'), ('capabilities', {'files': True})],
|
|
)
|
|
def test_invalid_source_metadata_types_fail_closed(tmp_path: Path, field: str, value: Any) -> None:
|
|
"""布尔值不能代替版本整数,无效来源元数据不能生成副本。"""
|
|
sdk = write_source(tmp_path)
|
|
path = tmp_path / 'src' / 'utils' / SDK_METADATA
|
|
metadata = json.loads(path.read_text(encoding='utf-8'))
|
|
metadata[field] = value
|
|
path.write_text(json.dumps(metadata), encoding='utf-8')
|
|
with pytest.raises(ValueError):
|
|
sdk.build_vendor_files()
|
|
|
|
|
|
def test_outdated_optional_capability_remains_protocol_compatible(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""旧版缺少 streams 时仍兼容通信协议,但 CI 检查不会误报为最新副本。"""
|
|
sdk, project = outdated
|
|
report = sdk.check(project)
|
|
assert not report['ok']
|
|
assert report['status'] == 'outdated'
|
|
assert report['sdkVersion'] == '0.9.0'
|
|
assert report['currentSdkVersion'] == '1.0.0'
|
|
assert report['protocolCompatible'] is True
|
|
assert report['updateAvailable'] is True
|
|
|
|
|
|
def test_update_previews_then_backs_up_exact_bytes_and_writes_metadata_last(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""预演零写入,实际更新先备份且最后替换来源记录,不改业务文件。"""
|
|
sdk, project = outdated
|
|
before = snapshot(project)
|
|
preview = sdk.update(project, dry_run=True)
|
|
assert preview['ok'] and preview['dryRun'] and not preview['updated']
|
|
assert snapshot(project) == before
|
|
assert not (project / '.plugin-sdk-backups').exists()
|
|
calls = []
|
|
replace = os.replace
|
|
|
|
def record_replace(source: Path, target: Path) -> None:
|
|
"""记录原子替换顺序以验证元数据只在代码之后发布。"""
|
|
calls.append(target.name)
|
|
replace(source, target)
|
|
|
|
monkeypatch.setattr(os, 'replace', record_replace)
|
|
report = sdk.update(project)
|
|
assert report['ok'] and report['updated'] and report['status'] == 'current'
|
|
assert calls == list(VENDOR_FILES)
|
|
backup = Path(report['backupDir'])
|
|
for name in VENDOR_FILES:
|
|
assert (backup / name).read_bytes() == before[str(Path('web/vendor') / name)]
|
|
assert (project / 'web/business.js').read_bytes() == before[str(Path('web/business.js'))]
|
|
assert not (project / '.plugin-sdk.lock').exists()
|
|
no_op = sdk.update(project)
|
|
assert no_op['ok'] and not no_op['updated'] and no_op['backupDir'] is None
|
|
assert len(list((project / '.plugin-sdk-backups').iterdir())) == 1
|
|
|
|
|
|
@pytest.mark.parametrize('state', ['modified', 'untracked', 'invalid', 'missing_file'])
|
|
def test_local_changes_require_force_and_are_backed_up(outdated: tuple[PluginBridgeSdk, Path], state: str) -> None:
|
|
"""本地改动、缺文件及来源记录损坏只允许显式备份后覆盖。"""
|
|
sdk, project = outdated
|
|
vendor = project / 'web/vendor'
|
|
if state == 'modified':
|
|
(vendor / SDK_FILES[0]).write_bytes(b'local edits\r\n')
|
|
elif state == 'untracked':
|
|
(vendor / SDK_METADATA).unlink()
|
|
elif state == 'invalid':
|
|
(vendor / SDK_METADATA).write_bytes(b'{broken')
|
|
else:
|
|
(vendor / SDK_FILES[1]).unlink()
|
|
before = snapshot(project)
|
|
expected = 'modified' if state == 'missing_file' else state
|
|
assert sdk.check(project)['status'] == expected
|
|
assert not sdk.update(project)['ok']
|
|
assert snapshot(project) == before
|
|
assert sdk.update(project, force=True, dry_run=True)['ok']
|
|
assert snapshot(project) == before
|
|
report = sdk.update(project, force=True)
|
|
assert report['ok'] and report['status'] == 'current'
|
|
backup = Path(report['backupDir'])
|
|
for name in VENDOR_FILES:
|
|
relative = str(Path('web/vendor') / name)
|
|
assert (backup / name).exists() == (relative in before)
|
|
if relative in before:
|
|
assert (backup / name).read_bytes() == before[relative]
|
|
|
|
|
|
def test_protocol_change_is_not_force_updatable(tmp_path: Path) -> None:
|
|
"""不同通信协议需要人工迁移,不能借 --force 越过该边界。"""
|
|
frontend = tmp_path / 'frontend'
|
|
sdk = write_source(frontend)
|
|
project = write_project(tmp_path / 'project', sdk)
|
|
write_source(frontend, wire=2)
|
|
before = snapshot(project)
|
|
report = sdk.check(project)
|
|
assert report['status'] == 'incompatible' and report['protocolCompatible'] is False
|
|
assert not sdk.update(project, force=True)['ok']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
@pytest.mark.parametrize('metadata_state', ['tracked', 'untracked', 'invalid'])
|
|
def test_actual_modified_wire_cannot_be_hidden_by_metadata(
|
|
outdated: tuple[PluginBridgeSdk, Path], metadata_state: str
|
|
) -> None:
|
|
"""实际导出协议不同即拒绝强制更新,不依赖旧记录是否存在或完整。"""
|
|
sdk, project = outdated
|
|
vendor = project / 'web/vendor'
|
|
path = vendor / SDK_FILES[0]
|
|
path.write_text(
|
|
path.read_text(encoding='utf-8').replace('PLUGIN_BRIDGE_VERSION = 1', 'PLUGIN_BRIDGE_VERSION = 2'),
|
|
encoding='utf-8',
|
|
)
|
|
if metadata_state == 'untracked':
|
|
(vendor / SDK_METADATA).unlink()
|
|
elif metadata_state == 'invalid':
|
|
(vendor / SDK_METADATA).write_bytes(b'{broken')
|
|
before = snapshot(project)
|
|
report = sdk.check(project)
|
|
assert report['status'] == 'incompatible'
|
|
assert report['protocolCompatible'] is False
|
|
assert not sdk.update(project, force=True)['ok']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
def test_metadata_wire_conflict_cannot_be_force_ignored(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""元数据声明不同协议时,不能仅凭代码的当前协议导出宣布兼容。"""
|
|
sdk, project = outdated
|
|
path = project / 'web/vendor' / SDK_METADATA
|
|
metadata = json.loads(path.read_text(encoding='utf-8'))
|
|
metadata['bridgeVersion'] = 2
|
|
path.write_text(json.dumps(metadata), encoding='utf-8')
|
|
before = snapshot(project)
|
|
assert sdk.check(project)['protocolCompatible'] is False
|
|
assert not sdk.update(project, force=True)['ok']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
def test_unrecognizable_actual_wire_remains_unknown(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""实际声明无法静态识别时不再使用旧元数据伪报兼容。"""
|
|
sdk, project = outdated
|
|
path = project / 'web/vendor' / SDK_FILES[0]
|
|
path.write_text('export const PLUGIN_BRIDGE_VERSION = getVersion()\n', encoding='utf-8')
|
|
report = sdk.check(project)
|
|
assert report['status'] == 'modified'
|
|
assert report['protocolCompatible'] is None
|
|
assert sdk.update(project, force=True)['ok']
|
|
|
|
|
|
def test_legacy_untracked_wire_one_without_sdk_version_can_be_adopted(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""早期没有独立 SDK 版本常量的协议 v1 副本仍能显式备份纳管。"""
|
|
sdk, project = outdated
|
|
vendor = project / 'web/vendor'
|
|
(vendor / SDK_METADATA).unlink()
|
|
for name in SDK_FILES:
|
|
path = vendor / name
|
|
text = path.read_text(encoding='utf-8')
|
|
path.write_text(
|
|
'\n'.join(line for line in text.splitlines() if 'PLUGIN_BRIDGE_SDK_VERSION' not in line), encoding='utf-8'
|
|
)
|
|
report = sdk.check(project)
|
|
assert report['status'] == 'untracked' and report['protocolCompatible'] is True
|
|
assert not sdk.update(project)['ok']
|
|
assert sdk.update(project, force=True)['ok']
|
|
|
|
|
|
@pytest.mark.parametrize('manifest', [{}, {'manifestVersion': 1}, {'manifestVersion': True}, {'manifestVersion': 2}])
|
|
def test_invalid_project_is_never_force_updatable(
|
|
outdated: tuple[PluginBridgeSdk, Path], manifest: dict[str, Any]
|
|
) -> None:
|
|
"""强制模式不允许更新非 v2 bundle 工程。"""
|
|
sdk, project = outdated
|
|
(project / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8')
|
|
before = snapshot(project)
|
|
assert sdk.check(project)['status'] == 'invalid'
|
|
assert not sdk.update(project, force=True)['ok']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
def test_existing_lock_is_preserved_and_prevents_update(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""其他更新持有的锁不会被覆盖或自动清理。"""
|
|
sdk, project = outdated
|
|
(project / '.plugin-sdk.lock').write_text('other updater', encoding='utf-8')
|
|
before = snapshot(project)
|
|
report = sdk.update(project)
|
|
assert not report['ok'] and '锁已存在' in report['message']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
@pytest.mark.parametrize('failure_file', list(VENDOR_FILES))
|
|
def test_failed_update_restores_originals_and_keeps_backup(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch, failure_file: str
|
|
) -> None:
|
|
"""任一替换失败均恢复原有三个文件,包括原始换行字节。"""
|
|
sdk, project = outdated
|
|
before = snapshot(project)
|
|
replace = os.replace
|
|
failed = False
|
|
|
|
def fail_once(source: Path, target: Path) -> None:
|
|
"""模拟磁盘替换失败一次,使后续回滚仍可写回原内容。"""
|
|
nonlocal failed
|
|
if target.name == failure_file and not failed:
|
|
failed = True
|
|
raise OSError('simulated replacement failure')
|
|
replace(source, target)
|
|
|
|
monkeypatch.setattr(os, 'replace', fail_once)
|
|
report = sdk.update(project)
|
|
assert not report['ok'] and not report['updated']
|
|
assert Path(report['backupDir']).is_dir()
|
|
for name in VENDOR_FILES:
|
|
assert (project / 'web/vendor' / name).read_bytes() == before[str(Path('web/vendor') / name)]
|
|
assert sdk.check(project)['status'] == 'outdated'
|
|
assert not (project / '.plugin-sdk.lock').exists()
|
|
assert not list((project / 'web/vendor').glob('*.tmp'))
|
|
|
|
|
|
def test_final_content_verification_failure_rolls_back(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""文件系统未报错但最终内容不匹配时仍回滚,不能宣布更新成功。"""
|
|
sdk, project = outdated
|
|
before = snapshot(project)
|
|
replace = os.replace
|
|
corrupted = False
|
|
|
|
def corrupt_once(source: Path, target: Path) -> None:
|
|
"""模拟元数据发布后发现实际文件字节不同。"""
|
|
nonlocal corrupted
|
|
replace(source, target)
|
|
if target.name == SDK_METADATA and not corrupted:
|
|
corrupted = True
|
|
(target.parent / SDK_FILES[0]).write_bytes(b'simulated corrupted bytes')
|
|
|
|
monkeypatch.setattr(os, 'replace', corrupt_once)
|
|
report = sdk.update(project)
|
|
assert not report['ok'] and not report['updated']
|
|
assert '内容校验失败' in report['message']
|
|
for name in VENDOR_FILES:
|
|
assert (project / 'web/vendor' / name).read_bytes() == before[str(Path('web/vendor') / name)]
|
|
assert sdk.check(project)['status'] == 'outdated'
|
|
|
|
|
|
def test_untracked_creation_failure_removes_new_files(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""纳管空 vendor 失败后删除本次新增 SDK 文件,不留下半套副本。"""
|
|
sdk, project = outdated
|
|
for name in VENDOR_FILES:
|
|
(project / 'web/vendor' / name).unlink()
|
|
replace = os.replace
|
|
|
|
def fail_metadata(source: Path, target: Path) -> None:
|
|
"""模拟首次创建副本时发布元数据失败。"""
|
|
if target.name == SDK_METADATA:
|
|
raise OSError('metadata write failed')
|
|
replace(source, target)
|
|
|
|
monkeypatch.setattr(os, 'replace', fail_metadata)
|
|
report = sdk.update(project, force=True)
|
|
assert not report['ok']
|
|
assert not list((project / 'web/vendor').iterdir())
|
|
assert not (project / '.plugin-sdk.lock').exists()
|
|
assert sdk.check(project)['status'] == 'untracked'
|
|
|
|
|
|
def test_rollback_failure_reports_backup_and_continues_other_files(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""恢复其中一个文件失败时继续处理其他文件,并明确提示使用备份。"""
|
|
sdk, project = outdated
|
|
before = snapshot(project)
|
|
replace = os.replace
|
|
|
|
def fail_types(source: Path, target: Path) -> None:
|
|
"""模拟类型声明文件持续无法替换。"""
|
|
if target.name == SDK_FILES[1]:
|
|
raise OSError('type file unavailable')
|
|
replace(source, target)
|
|
|
|
monkeypatch.setattr(os, 'replace', fail_types)
|
|
report = sdk.update(project)
|
|
assert not report['ok'] and '回滚未完成' in report['message']
|
|
backup = Path(report['backupDir'])
|
|
assert str(backup) in report['message']
|
|
for name in VENDOR_FILES:
|
|
assert (backup / name).read_bytes() == before[str(Path('web/vendor') / name)]
|
|
assert (project / 'web/vendor' / SDK_FILES[0]).read_bytes() == before[str(Path('web/vendor') / SDK_FILES[0])]
|
|
assert not (project / '.plugin-sdk.lock').exists()
|
|
|
|
|
|
@pytest.mark.parametrize('relative', ['web', 'web/vendor', 'web/vendor/pluginBridge.js', '.plugin-sdk-backups'])
|
|
def test_windows_reparse_components_are_rejected(
|
|
outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch, relative: str
|
|
) -> None:
|
|
"""无需创建链接权限也覆盖 Windows 重解析点及父路径拒绝逻辑。"""
|
|
sdk, project = outdated
|
|
before = snapshot(project)
|
|
rejected = project / relative
|
|
lstat = Path.lstat
|
|
|
|
def reparse_lstat(path: Path) -> Any:
|
|
"""将指定路径模拟为 Windows 重解析点。"""
|
|
if path == rejected:
|
|
return SimpleNamespace(st_mode=stat.S_IFDIR, st_file_attributes=0x400)
|
|
return lstat(path)
|
|
|
|
monkeypatch.setattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400, raising=False)
|
|
monkeypatch.setattr(Path, 'lstat', reparse_lstat)
|
|
report = sdk.update(project, force=True)
|
|
assert not report['ok'] and '重解析点' in report['message']
|
|
assert snapshot(project) == before
|
|
|
|
|
|
def test_metadata_cannot_choose_target_paths(outdated: tuple[PluginBridgeSdk, Path]) -> None:
|
|
"""篡改摘要键不能让更新读取或覆盖固定三文件以外的路径。"""
|
|
sdk, project = outdated
|
|
metadata_path = project / 'web/vendor' / SDK_METADATA
|
|
metadata = json.loads(metadata_path.read_text(encoding='utf-8'))
|
|
metadata['files']['../business.js'] = metadata['files'].pop(SDK_FILES[0])
|
|
metadata_path.write_text(json.dumps(metadata), encoding='utf-8')
|
|
before = (project / 'web/business.js').read_bytes()
|
|
assert sdk.check(project)['status'] == 'invalid'
|
|
assert not sdk.update(project)['ok']
|
|
assert sdk.update(project, force=True)['ok']
|
|
assert (project / 'web/business.js').read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize('target_name', ['pluginBridge.js', 'pluginBridge.sdk.json', '.plugin-sdk-backups'])
|
|
def test_symlinks_are_rejected_without_touching_target(
|
|
outdated: tuple[PluginBridgeSdk, Path], tmp_path: Path, target_name: str
|
|
) -> None:
|
|
"""SDK 文件和备份路径中的符号链接不能作为可强制覆盖的普通副本。"""
|
|
sdk, project = outdated
|
|
directory = target_name == '.plugin-sdk-backups'
|
|
target = tmp_path / 'outside'
|
|
if directory:
|
|
target.mkdir()
|
|
else:
|
|
target.write_bytes(b'outside unchanged')
|
|
link = project / target_name if directory else project / 'web/vendor' / target_name
|
|
if link.exists():
|
|
link.unlink()
|
|
try:
|
|
link.symlink_to(target, target_is_directory=directory)
|
|
except OSError as exc:
|
|
pytest.skip(f'当前平台不允许创建测试符号链接:{exc}')
|
|
assert not sdk.update(project, force=True)['ok']
|
|
if directory:
|
|
assert not list(target.iterdir())
|
|
else:
|
|
assert target.read_bytes() == b'outside unchanged'
|
|
assert link.is_symlink()
|