SkillFileSyncerTest.java 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. package com.agent.management.engine;
  2. import com.agent.management.config.SkillProperties;
  3. import org.junit.jupiter.api.BeforeEach;
  4. import org.junit.jupiter.api.Test;
  5. import org.junit.jupiter.api.io.TempDir;
  6. import java.io.IOException;
  7. import java.nio.file.Files;
  8. import java.nio.file.Path;
  9. import static org.assertj.core.api.Assertions.assertThat;
  10. /**
  11. * {@link SkillFileSyncer} 单元测试。
  12. *
  13. * <p>覆盖镜像同步的跳过规则:
  14. * <ul>
  15. * <li>技能根目录 SKILL.md(合法主入口)正常复制;</li>
  16. * <li>{@code _versions/} 子目录整树跳过;</li>
  17. * <li>非根子目录直接含 SKILL.md 时整树跳过(任意深度,常见于 AI 生成残留);</li>
  18. * <li>目标侧残留清除;</li>
  19. * <li>普通子目录与文件正常复制。</li>
  20. * </ul>
  21. */
  22. class SkillFileSyncerTest {
  23. @TempDir
  24. Path tempDir;
  25. private Path sourceRoot;
  26. private Path targetRoot;
  27. private SkillFileSyncer syncer;
  28. @BeforeEach
  29. void setUp() throws IOException {
  30. sourceRoot = tempDir.resolve("uploads-skills");
  31. targetRoot = tempDir.resolve("hermes-home-skills");
  32. Files.createDirectories(sourceRoot);
  33. SkillProperties props = new SkillProperties();
  34. props.setBasePath(sourceRoot.toString());
  35. syncer = new SkillFileSyncer(props);
  36. }
  37. /** 创建技能根目录 + SKILL.md */
  38. private void createSkill(String folderName, String skillMdContent) throws IOException {
  39. Path skillDir = sourceRoot.resolve(folderName);
  40. Files.createDirectories(skillDir);
  41. Files.writeString(skillDir.resolve("SKILL.md"), skillMdContent);
  42. }
  43. /** 在技能内创建子目录(可选拓展子文件) */
  44. private Path mkSubDir(String skillFolder, String subRel) throws IOException {
  45. Path p = sourceRoot.resolve(skillFolder).resolve(subRel);
  46. Files.createDirectories(p);
  47. return p;
  48. }
  49. private void write(Path dir, String fileName, String content) throws IOException {
  50. Files.writeString(dir.resolve(fileName), content);
  51. }
  52. // ==================== 测试用例 ====================
  53. @Test
  54. void copiesRootSkillMdAndRegularFiles() throws IOException {
  55. createSkill("foo", "name: foo\n");
  56. write(sourceRoot.resolve("foo"), "notes.md", "hello");
  57. int copied = syncer.syncTo(targetRoot);
  58. assertThat(copied).isEqualTo(1);
  59. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  60. assertThat(targetRoot.resolve("foo/notes.md")).exists();
  61. }
  62. @Test
  63. void skipsVersionsSubtree() throws IOException {
  64. createSkill("foo", "name: foo\n");
  65. Path versions = mkSubDir("foo", "_versions/v1");
  66. write(versions, "SKILL.md", "name: foo-old\n");
  67. syncer.syncTo(targetRoot);
  68. assertThat(targetRoot.resolve("foo/_versions")).doesNotExist();
  69. }
  70. @Test
  71. void skipsChildDirContainingSkillMd_generatedSkills() throws IOException {
  72. createSkill("foo", "name: foo\n");
  73. // 模拟 AI 生成的残留:foo/generated_skills/SKILL.md
  74. Path genDir = mkSubDir("foo", "generated_skills");
  75. write(genDir, "SKILL.md", "name: generated\n");
  76. write(genDir, "extra.txt", "keep me?");
  77. syncer.syncTo(targetRoot);
  78. // generated_skills/ 整树不复制
  79. assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
  80. // 但技能自身的 SKILL.md 仍正常
  81. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  82. }
  83. @Test
  84. void skipsChildDirContainingSkillMd_tmpSmokeGenerated() throws IOException {
  85. createSkill("foo", "name: foo\n");
  86. Path genDir = mkSubDir("foo", "tmp_smoke_generated");
  87. write(genDir, "SKILL.md", "name: smoke\n");
  88. syncer.syncTo(targetRoot);
  89. assertThat(targetRoot.resolve("foo/tmp_smoke_generated")).doesNotExist();
  90. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  91. }
  92. @Test
  93. void skipsNestedDirContainingSkillMd_atDeeperLevel() throws IOException {
  94. // 结构: foo/subdir/nested/SKILL.md,subdir 自身不含 SKILL.md
  95. // 期望:跳过 subdir/nested/(nested 整树),但 subdir/ 自身被创建为目录
  96. createSkill("foo", "name: foo\n");
  97. Path nested = mkSubDir("foo", "subdir/nested");
  98. write(nested, "SKILL.md", "name: nested\n");
  99. write(nested, "aux.txt", "aux");
  100. syncer.syncTo(targetRoot);
  101. // nested/ 整树被跳过
  102. assertThat(targetRoot.resolve("foo/subdir/nested")).doesNotExist();
  103. // subdir/ 本身仍创建(因为 preVisitDirectory 在 subdir 处返回 CONTINUE)
  104. assertThat(targetRoot.resolve("foo/subdir")).exists().isDirectory();
  105. // 技能根 SKILL.md 仍正常
  106. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  107. }
  108. @Test
  109. void regularSubdirWithoutSkillMdIsCopied() throws IOException {
  110. createSkill("foo", "name: foo\n");
  111. Path sub = mkSubDir("foo", "docs");
  112. write(sub, "guide.md", "guide");
  113. write(sub, "examples.py", "print('hi')");
  114. syncer.syncTo(targetRoot);
  115. assertThat(targetRoot.resolve("foo/docs/guide.md")).exists();
  116. assertThat(targetRoot.resolve("foo/docs/examples.py")).exists();
  117. }
  118. @Test
  119. void cleansOrphanedTargetDirs() throws IOException {
  120. // 目标侧已存在一个源侧没有的目录,同步后应被清除
  121. Path orphan = targetRoot.resolve("deleted-skill");
  122. Files.createDirectories(orphan);
  123. Files.writeString(orphan.resolve("SKILL.md"), "name: deleted\n");
  124. createSkill("foo", "name: foo\n");
  125. syncer.syncTo(targetRoot);
  126. assertThat(orphan).doesNotExist();
  127. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  128. }
  129. @Test
  130. void mirrorsSourceFileDeletionWithinSkill() throws IOException {
  131. // 首次同步:foo 含 SKILL.md + obsolete.md
  132. createSkill("foo", "name: foo\n");
  133. write(sourceRoot.resolve("foo"), "obsolete.md", "will be deleted");
  134. syncer.syncTo(targetRoot);
  135. assertThat(targetRoot.resolve("foo/obsolete.md")).exists();
  136. // 删除源侧 obsolete.md,再次同步
  137. Files.delete(sourceRoot.resolve("foo").resolve("obsolete.md"));
  138. syncer.syncTo(targetRoot);
  139. // 目标侧 obsolete.md 应被镜像清除(先删后拷语义)
  140. assertThat(targetRoot.resolve("foo/obsolete.md")).doesNotExist();
  141. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  142. }
  143. @Test
  144. void multipleSkillsAllChecked() throws IOException {
  145. createSkill("foo", "name: foo\n");
  146. createSkill("bar", "name: bar\n");
  147. // foo 下有残留
  148. Path genFoo = mkSubDir("foo", "generated_skills");
  149. write(genFoo, "SKILL.md", "name: gen\n");
  150. // bar 下正常
  151. Path docsBar = mkSubDir("bar", "docs");
  152. write(docsBar, "x.md", "x");
  153. syncer.syncTo(targetRoot);
  154. assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
  155. assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
  156. assertThat(targetRoot.resolve("bar/SKILL.md")).exists();
  157. assertThat(targetRoot.resolve("bar/docs/x.md")).exists();
  158. }
  159. @Test
  160. void returnsZeroWhenSourceMissing() throws IOException {
  161. // sourceRoot 不存在(删掉)
  162. sourceRoot.toFile().delete();
  163. int copied = syncer.syncTo(targetRoot);
  164. assertThat(copied).isZero();
  165. }
  166. }