Files
Octop/tests/unit/agents/test_skill_packages.py
Georgyhongbo 01ba6de0b8 fix: 保留技能 ZIP 导入时的空目录与根级技能的子文件夹
技能 ZIP 里仅含空目录(如 ai/、data/、scripts/)时,导入后目录会全部丢失:
此前解析 ZIP 直接跳过目录条目,写入时也只调用了文件上传。

- 前端 parseSkillZip:不再跳过目录条目,空目录转为带尾部 "/" 的路径标记
  (content 为空)保留;根级技能下的子文件夹(无自己的 SKILL.md)作为
  支撑文件一并保留,避免嵌套目录在导入时被丢弃。
- skill_packages:_normalize_relative_path 保留尾随 "/" 目录标记,
  不再被 PurePosixPath 悄悄当成文件名。
- skill_package_store:write_skill 对目录标记执行 mkdir,重建空目录。
- skills 路由:新增 _write_skill_files,先 amkdir 空目录再批量上传文件,
  create / update / URL 导入统一走该函数。
- 补充单元与集成测试:空目录在包存储与工作区中均被重建;孤立的空目录
  不会产生伪技能;根级技能与兄弟技能能正确共存。

验证:unit(agents) 技能相关用例通过、ruff clean、前端 parseSkillZip 测试通过。
2026-08-11 14:04:08 +08:00

197 lines
6.6 KiB
Python

"""Tests for source-neutral skill package normalization."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from octop.infra.skills import skill_packages
def test_resolve_package_from_an_independent_market() -> None:
package = skill_packages.resolve_skill_package(
slug="other-market-skill",
files=[
("SKILL.md", b"---\nname: other-market-skill\n---\n"),
("assets/icon.bin", b"\x00\x01"),
],
source="other-market",
source_url="https://market.example/skills/other-market-skill",
)
assert package.source == "other-market"
assert package.source_url == "https://market.example/skills/other-market-skill"
assert package.workspace_uploads() == [
(
"skills/other-market-skill/SKILL.md",
b"---\nname: other-market-skill\n---\n",
),
("skills/other-market-skill/assets/icon.bin", b"\x00\x01"),
]
def test_resolve_legacy_import_uploads_uses_the_same_pipeline() -> None:
package = skill_packages.resolve_workspace_uploads(
slug="github-skill",
uploads=[
("skills/github-skill/SKILL.md", b"# skill"),
("skills/github-skill/references/doc.md", b"# doc"),
],
source="url",
source_url="https://github.com/example/repo",
)
assert dict(package.files) == {
"SKILL.md": b"# skill",
"references/doc.md": b"# doc",
}
@pytest.mark.parametrize(
"path",
[
"../escape",
"/absolute",
"nested/../../escape",
"nested\\escape",
"C:/windows",
],
)
def test_resolve_package_rejects_unsafe_paths(path: str) -> None:
with pytest.raises(skill_packages.SkillPackageError):
skill_packages.resolve_skill_package(
slug="safe",
files=[("SKILL.md", b"# skill"), (path, b"unsafe")],
source="test",
)
def test_resolve_package_preserves_empty_directory_markers() -> None:
package = skill_packages.resolve_skill_package(
slug="word-docx",
files=[
("SKILL.md", b"---\nname: word-docx\n---\n"),
("ai/", b""),
("data/", b""),
("scripts/", b""),
("index.html", b"<html></html>"),
],
source="zip",
)
assert list(package.files) == [
("SKILL.md", b"---\nname: word-docx\n---\n"),
("ai/", b""),
("data/", b""),
("scripts/", b""),
("index.html", b"<html></html>"),
]
assert package.workspace_uploads() == [
("skills/word-docx/SKILL.md", b"---\nname: word-docx\n---\n"),
("skills/word-docx/ai/", b""),
("skills/word-docx/data/", b""),
("skills/word-docx/scripts/", b""),
("skills/word-docx/index.html", b"<html></html>"),
]
def test_normalize_relative_path_keeps_dir_marker_while_cleaning_others() -> None:
assert skill_packages._normalize_relative_path("ai/") == "ai/"
assert skill_packages._normalize_relative_path("nested/scripts/") == "nested/scripts/"
assert skill_packages._normalize_relative_path("scripts") == "scripts"
assert skill_packages._normalize_relative_path("./docs.md") == "docs.md"
def test_resolve_import_preserves_empty_directory_markers() -> None:
package = skill_packages.resolve_workspace_uploads(
slug="gh-skill",
uploads=[
("skills/gh-skill/SKILL.md", b"# skill"),
("skills/gh-skill/empty/", b""),
],
source="url",
)
assert list(package.files) == [("SKILL.md", b"# skill"), ("empty/", b"")]
def test_resolve_import_rejects_files_outside_the_skill_root() -> None:
with pytest.raises(skill_packages.SkillPackageError, match="outside"):
skill_packages.resolve_workspace_uploads(
slug="safe",
uploads=[
("skills/safe/SKILL.md", b"# skill"),
("skills/other/escape.md", b"unsafe"),
],
source="url",
)
def test_resolve_package_enforces_shared_size_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(skill_packages, "MAX_SKILL_BYTES", 4)
with pytest.raises(skill_packages.SkillPackageTooLarge, match="64 MB"):
skill_packages.resolve_skill_package(
slug="large",
files=[("SKILL.md", b"12345")],
source="test",
)
def test_resolve_package_requires_utf8_manifest() -> None:
with pytest.raises(skill_packages.SkillPackageError, match="UTF-8"):
skill_packages.resolve_skill_package(
slug="binary-manifest",
files=[("SKILL.md", b"\xff\xfe")],
source="test",
)
@pytest.mark.skipif(os.name != "posix", reason="symlink semantics are POSIX-specific")
def test_read_skill_directory_rejects_symlink(tmp_path: Path) -> None:
(tmp_path / "SKILL.md").write_text("# skill", encoding="utf-8")
(tmp_path / "target").write_text("target", encoding="utf-8")
(tmp_path / "link").symlink_to(tmp_path / "target")
with pytest.raises(skill_packages.SkillPackageError, match="file type"):
skill_packages.read_skill_directory(tmp_path)
def test_read_cli_skill_install_accepts_files_at_install_root(tmp_path: Path) -> None:
(tmp_path / "SKILL.md").write_text("# root skill", encoding="utf-8")
assert skill_packages.read_cli_skill_install(tmp_path) == [("SKILL.md", b"# root skill")]
def test_read_cli_skill_install_discovers_single_wrapper_directory(tmp_path: Path) -> None:
wrapper = tmp_path / "cli-generated-directory"
wrapper.mkdir()
(wrapper / "SKILL.md").write_text("# wrapped skill", encoding="utf-8")
assert skill_packages.read_cli_skill_install(tmp_path) == [("SKILL.md", b"# wrapped skill")]
def test_read_cli_skill_install_rejects_ambiguous_packages(tmp_path: Path) -> None:
for name in ("one", "two"):
wrapper = tmp_path / name
wrapper.mkdir()
(wrapper / "SKILL.md").write_text(f"# {name}", encoding="utf-8")
with pytest.raises(skill_packages.SkillPackageError, match="multiple"):
skill_packages.read_cli_skill_install(tmp_path)
@pytest.mark.skipif(os.name != "posix", reason="symlink semantics are POSIX-specific")
def test_read_cli_skill_install_does_not_follow_wrapper_symlink(tmp_path: Path) -> None:
outside = tmp_path.parent / "outside-skill"
outside.mkdir()
(outside / "SKILL.md").write_text("# outside", encoding="utf-8")
(tmp_path / "wrapper").symlink_to(outside, target_is_directory=True)
with pytest.raises(skill_packages.SkillPackageError, match="did not produce"):
skill_packages.read_cli_skill_install(tmp_path)