glob.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Cause, Effect, Exit, Layer } from "effect"
  4. import { GlobTool } from "../../src/tool/glob"
  5. import { SessionID, MessageID } from "../../src/session/schema"
  6. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  7. import { Ripgrep } from "../../src/file/ripgrep"
  8. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { Truncate } from "@/tool/truncate"
  11. import { Agent } from "../../src/agent/agent"
  12. import { TestInstance, tmpdirScoped } from "../fixture/fixture"
  13. import { testEffect } from "../lib/effect"
  14. import { Reference } from "@/reference/reference"
  15. import { RepositoryCache } from "@/reference/repository-cache"
  16. import { Config } from "@/config/config"
  17. import { RuntimeFlags } from "@/effect/runtime-flags"
  18. import { Git } from "@/git"
  19. import { Permission } from "../../src/permission"
  20. import type * as Tool from "../../src/tool/tool"
  21. const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  22. Reference.layer.pipe(
  23. Layer.provide(Config.defaultLayer),
  24. Layer.provide(RepositoryCache.defaultLayer),
  25. Layer.provide(RuntimeFlags.layer(flags)),
  26. )
  27. const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  28. Layer.mergeAll(
  29. CrossSpawnSpawner.defaultLayer,
  30. AppFileSystem.defaultLayer,
  31. Ripgrep.defaultLayer,
  32. Truncate.defaultLayer,
  33. Agent.defaultLayer,
  34. Git.defaultLayer,
  35. referenceLayer(flags),
  36. )
  37. const it = testEffect(toolLayer())
  38. const scout = testEffect(toolLayer({ experimentalScout: true }))
  39. const ctx = {
  40. sessionID: SessionID.make("ses_test"),
  41. messageID: MessageID.make("msg_test"),
  42. callID: "",
  43. agent: "build",
  44. abort: AbortSignal.any([]),
  45. messages: [],
  46. metadata: () => Effect.void,
  47. ask: () => Effect.void,
  48. }
  49. const asks = () => {
  50. const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
  51. return {
  52. items,
  53. next: {
  54. ...ctx,
  55. ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
  56. Effect.sync(() => {
  57. items.push(req)
  58. }),
  59. } satisfies Tool.Context,
  60. }
  61. }
  62. const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
  63. Effect.acquireUseRelease(
  64. Effect.sync(() => {
  65. const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  66. process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
  67. return previous
  68. }),
  69. () => self,
  70. (previous) =>
  71. Effect.sync(() => {
  72. if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
  73. else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  74. }),
  75. )
  76. const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) {
  77. return yield* Effect.promise(async () => {
  78. const proc = Bun.spawn(["git", ...args], {
  79. cwd,
  80. stdout: "pipe",
  81. stderr: "pipe",
  82. })
  83. const [stdout, stderr, code] = await Promise.all([
  84. new Response(proc.stdout).text(),
  85. new Response(proc.stderr).text(),
  86. proc.exited,
  87. ])
  88. if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
  89. return stdout.trim()
  90. })
  91. })
  92. describe("tool.glob", () => {
  93. it.instance("matches files from a directory path", () =>
  94. Effect.gen(function* () {
  95. const test = yield* TestInstance
  96. yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n"))
  97. yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n"))
  98. const info = yield* GlobTool
  99. const glob = yield* info.init()
  100. const result = yield* glob.execute(
  101. {
  102. pattern: "*.ts",
  103. path: test.directory,
  104. },
  105. ctx,
  106. )
  107. expect(result.metadata.count).toBe(1)
  108. expect(result.output).toContain(path.join(test.directory, "a.ts"))
  109. expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
  110. }),
  111. )
  112. it.instance("rejects exact file paths", () =>
  113. Effect.gen(function* () {
  114. const test = yield* TestInstance
  115. const file = path.join(test.directory, "a.ts")
  116. yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
  117. const info = yield* GlobTool
  118. const glob = yield* info.init()
  119. const exit = yield* glob
  120. .execute(
  121. {
  122. pattern: "*.ts",
  123. path: file,
  124. },
  125. ctx,
  126. )
  127. .pipe(Effect.exit)
  128. expect(Exit.isFailure(exit)).toBe(true)
  129. if (Exit.isFailure(exit)) {
  130. const err = Cause.squash(exit.cause)
  131. expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
  132. }
  133. }),
  134. )
  135. scout.instance(
  136. "does not ask for external_directory permission inside configured git references",
  137. () =>
  138. Effect.gen(function* () {
  139. yield* TestInstance
  140. const fs = yield* AppFileSystem.Service
  141. const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
  142. yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
  143. yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
  144. const source = yield* tmpdirScoped({ git: true })
  145. const remoteRoot = yield* tmpdirScoped()
  146. const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
  147. const remoteRepo = path.join(remoteDir, "repo.git")
  148. yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
  149. yield* git(source, ["add", "."])
  150. yield* git(source, ["commit", "-m", "add source"])
  151. yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
  152. yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
  153. const { items, next } = asks()
  154. const info = yield* GlobTool
  155. const glob = yield* info.init()
  156. const result = yield* githubBase(
  157. `file://${remoteRoot}/`,
  158. glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
  159. )
  160. expect(result.metadata.count).toBe(1)
  161. expect(result.output).toContain(path.join(cache, "src", "index.ts"))
  162. expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
  163. }),
  164. {
  165. config: {
  166. reference: {
  167. docs: "opencode-glob-reference/repo",
  168. },
  169. },
  170. },
  171. )
  172. })