discovery.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { describe, expect, beforeAll, afterAll } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { FSUtil } from "@kirincode-ai/core/fs-util"
  4. import { Effect } from "effect"
  5. import { Discovery } from "../../src/skill/discovery"
  6. import { Global } from "@kirincode-ai/core/global"
  7. import { Filesystem } from "@/util/filesystem"
  8. import { rm } from "fs/promises"
  9. import path from "path"
  10. import { testEffect } from "../lib/effect"
  11. let CLOUDFLARE_SKILLS_URL: string
  12. let server: ReturnType<typeof Bun.serve>
  13. let downloadCount = 0
  14. let mutableVersion = "1"
  15. let mutableContent = "# Old"
  16. let mutableDownloadCount = 0
  17. let mutableFiles = ["SKILL.md"]
  18. const fixturePath = path.join(import.meta.dir, "../fixture/skills")
  19. const cacheDir = path.join(Global.Path.cache, "skills")
  20. const it = testEffect(LayerNode.compile(LayerNode.group([Discovery.node, FSUtil.node])))
  21. beforeAll(async () => {
  22. await rm(cacheDir, { recursive: true, force: true })
  23. server = Bun.serve({
  24. port: 0,
  25. async fetch(req) {
  26. const url = new URL(req.url)
  27. if (url.pathname === "/mutable/index.json") {
  28. return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
  29. }
  30. if (url.pathname === "/mutable/mutable/SKILL.md") {
  31. mutableDownloadCount++
  32. return new Response(mutableContent)
  33. }
  34. if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
  35. // route /.well-known/skills/* to the fixture directory
  36. if (url.pathname.startsWith("/.well-known/skills/")) {
  37. const filePath = url.pathname.replace("/.well-known/skills/", "")
  38. const fullPath = path.join(fixturePath, filePath)
  39. if (await Filesystem.exists(fullPath)) {
  40. if (!fullPath.endsWith("index.json")) {
  41. downloadCount++
  42. }
  43. return new Response(Bun.file(fullPath))
  44. }
  45. }
  46. return new Response("Not Found", { status: 404 })
  47. },
  48. })
  49. CLOUDFLARE_SKILLS_URL = `http://localhost:${server.port}/.well-known/skills/`
  50. })
  51. afterAll(async () => {
  52. void server?.stop()
  53. await rm(cacheDir, { recursive: true, force: true })
  54. })
  55. describe("Discovery.pull", () => {
  56. it.live("downloads skills from cloudflare url", () =>
  57. Effect.gen(function* () {
  58. const fsys = yield* FSUtil.Service
  59. const discovery = yield* Discovery.Service
  60. const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
  61. expect(dirs.length).toBeGreaterThan(0)
  62. for (const dir of dirs) {
  63. expect(dir).toStartWith(cacheDir)
  64. const md = path.join(dir, "SKILL.md")
  65. expect(yield* fsys.existsSafe(md)).toBe(true)
  66. }
  67. }),
  68. )
  69. it.live("url without trailing slash works", () =>
  70. Effect.gen(function* () {
  71. const fsys = yield* FSUtil.Service
  72. const discovery = yield* Discovery.Service
  73. const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
  74. expect(dirs.length).toBeGreaterThan(0)
  75. for (const dir of dirs) {
  76. const md = path.join(dir, "SKILL.md")
  77. expect(yield* fsys.existsSafe(md)).toBe(true)
  78. }
  79. }),
  80. )
  81. it.live("returns empty array for invalid url", () =>
  82. Effect.gen(function* () {
  83. const discovery = yield* Discovery.Service
  84. const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
  85. expect(dirs).toEqual([])
  86. }),
  87. )
  88. it.live("returns empty array for non-json response", () =>
  89. Effect.gen(function* () {
  90. // any url not explicitly handled in server returns 404 text "Not Found"
  91. const discovery = yield* Discovery.Service
  92. const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
  93. expect(dirs).toEqual([])
  94. }),
  95. )
  96. it.live("downloads reference files alongside SKILL.md", () =>
  97. Effect.gen(function* () {
  98. const fsys = yield* FSUtil.Service
  99. const discovery = yield* Discovery.Service
  100. const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
  101. // find a skill dir that should have reference files (e.g. agents-sdk)
  102. const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
  103. expect(agentsSdk).toBeDefined()
  104. if (agentsSdk) {
  105. const refs = path.join(agentsSdk, "references")
  106. expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true)
  107. // agents-sdk has reference files per the index
  108. const refDir = yield* Effect.promise(() =>
  109. Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
  110. )
  111. expect(refDir.length).toBeGreaterThan(0)
  112. }
  113. }),
  114. )
  115. it.live("caches downloaded files on second pull", () =>
  116. Effect.gen(function* () {
  117. // clear dir and downloadCount
  118. yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
  119. downloadCount = 0
  120. const discovery = yield* Discovery.Service
  121. // first pull to populate cache
  122. const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
  123. expect(first.length).toBeGreaterThan(0)
  124. const firstCount = downloadCount
  125. expect(firstCount).toBeGreaterThan(0)
  126. // second pull should return same results from cache
  127. const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
  128. expect(second.length).toBe(first.length)
  129. expect(second.sort()).toEqual(first.sort())
  130. // second pull should NOT increment download count
  131. expect(downloadCount).toBe(firstCount)
  132. }),
  133. )
  134. it.live("refreshes a remote skill when its version changes", () =>
  135. Effect.gen(function* () {
  136. yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
  137. mutableVersion = "1"
  138. mutableContent = "# Old"
  139. mutableDownloadCount = 0
  140. mutableFiles = ["SKILL.md", "old.md"]
  141. const discovery = yield* Discovery.Service
  142. const url = `http://localhost:${server.port}/mutable/`
  143. const first = yield* discovery.pull(url)
  144. expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
  145. mutableVersion = "2"
  146. mutableContent = "# Partial"
  147. mutableFiles = ["SKILL.md", "missing.md"]
  148. const second = yield* discovery.pull(url)
  149. expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
  150. expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
  151. mutableVersion = "3"
  152. mutableContent = "# New"
  153. mutableFiles = ["SKILL.md"]
  154. yield* discovery.pull(url)
  155. expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
  156. expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
  157. expect(mutableDownloadCount).toBe(3)
  158. yield* discovery.pull(url)
  159. expect(mutableDownloadCount).toBe(3)
  160. }),
  161. )
  162. })