reference.test.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Effect, Layer } from "effect"
  4. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  5. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  6. import { Flag } from "@opencode-ai/core/flag/flag"
  7. import { Global } from "@opencode-ai/core/global"
  8. import { Git } from "../../src/git"
  9. import { Reference } from "../../src/reference/reference"
  10. import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. afterEach(async () => {
  13. await disposeAllInstances()
  14. })
  15. const it = testEffect(
  16. Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer),
  17. )
  18. const experimentalScout = <A, E, R>(self: Effect.Effect<A, E, R>) =>
  19. Effect.acquireUseRelease(
  20. Effect.sync(() => {
  21. const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT
  22. Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
  23. return previous
  24. }),
  25. () => self,
  26. (previous) =>
  27. Effect.sync(() => {
  28. Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous
  29. }),
  30. )
  31. const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
  32. Effect.acquireUseRelease(
  33. Effect.sync(() => {
  34. const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  35. process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
  36. return previous
  37. }),
  38. () => self,
  39. (previous) =>
  40. Effect.sync(() => {
  41. if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
  42. else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
  43. }),
  44. )
  45. const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) {
  46. return yield* Effect.promise(async () => {
  47. const proc = Bun.spawn(["git", ...args], {
  48. cwd,
  49. stdout: "pipe",
  50. stderr: "pipe",
  51. })
  52. const [stdout, stderr, code] = await Promise.all([
  53. new Response(proc.stdout).text(),
  54. new Response(proc.stderr).text(),
  55. proc.exited,
  56. ])
  57. if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
  58. return stdout.trim()
  59. })
  60. })
  61. const waitForContent = (
  62. fs: AppFileSystem.Interface,
  63. file: string,
  64. content: string,
  65. attempts = 50,
  66. ): Effect.Effect<void, AppFileSystem.Error> =>
  67. Effect.gen(function* () {
  68. if ((yield* fs.readFileStringSafe(file)) === content) return
  69. if (attempts <= 0) throw new Error(`timed out waiting for ${file}`)
  70. yield* Effect.sleep("100 millis")
  71. yield* waitForContent(fs, file, content, attempts - 1)
  72. })
  73. describe("reference", () => {
  74. it.live("resolves local and git references", () =>
  75. Effect.gen(function* () {
  76. const root = path.resolve("opencode-reference-root")
  77. const local = Reference.resolve({
  78. name: "docs",
  79. reference: { path: "../docs" },
  80. directory: path.join(root, "packages", "app"),
  81. worktree: root,
  82. })
  83. const repo = Reference.resolve({
  84. name: "effect",
  85. reference: { repository: "Effect-TS/effect", branch: "main" },
  86. directory: path.join(root, "packages", "app"),
  87. worktree: root,
  88. })
  89. expect(local.kind).toBe("local")
  90. if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
  91. expect(repo.kind).toBe("git")
  92. if (repo.kind === "git") {
  93. expect(repo.repository).toBe("Effect-TS/effect")
  94. expect(repo.branch).toBe("main")
  95. expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
  96. }
  97. }),
  98. )
  99. it.live("marks same-cache references with different branches invalid", () =>
  100. Effect.gen(function* () {
  101. const root = path.resolve("opencode-reference-root")
  102. const references = Reference.resolveAll({
  103. directory: root,
  104. worktree: root,
  105. references: {
  106. main: { repository: "owner/repo", branch: "main" },
  107. dev: { repository: "github.com/owner/repo", branch: "dev" },
  108. alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
  109. },
  110. })
  111. expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
  112. expect(references[1]?.kind).toBe("invalid")
  113. if (references[1]?.kind === "invalid") {
  114. expect(references[1].message).toContain("conflicts with @main")
  115. expect(references[1].message).toContain("@dev requests dev")
  116. }
  117. }),
  118. )
  119. it.live("materializes configured git references during init", () =>
  120. experimentalScout(
  121. provideTmpdirInstance(
  122. (_dir) =>
  123. Effect.gen(function* () {
  124. const fs = yield* AppFileSystem.Service
  125. const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
  126. yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
  127. yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
  128. const source = yield* tmpdirScoped({ git: true })
  129. const remoteRoot = yield* tmpdirScoped()
  130. const remoteDir = path.join(remoteRoot, "opencode-reference-test")
  131. const remoteRepo = path.join(remoteDir, "repo.git")
  132. yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
  133. yield* git(source, ["add", "."])
  134. yield* git(source, ["commit", "-m", "add readme"])
  135. yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
  136. yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
  137. const reference = yield* Reference.Service
  138. yield* githubBase(
  139. `file://${remoteRoot}/`,
  140. Effect.gen(function* () {
  141. yield* reference.init()
  142. yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
  143. }),
  144. )
  145. expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true)
  146. expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n")
  147. const resolved = yield* reference.get("docs")
  148. expect(resolved?.kind).toBe("git")
  149. if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
  150. }),
  151. {
  152. config: {
  153. reference: {
  154. docs: "opencode-reference-test/repo",
  155. },
  156. },
  157. },
  158. ),
  159. ),
  160. )
  161. it.live("refreshes configured git references on new instance init", () =>
  162. experimentalScout(
  163. Effect.gen(function* () {
  164. const fs = yield* AppFileSystem.Service
  165. const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
  166. yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
  167. yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
  168. const source = yield* tmpdirScoped({ git: true })
  169. const remoteRoot = yield* tmpdirScoped()
  170. const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
  171. const remoteRepo = path.join(remoteDir, "repo.git")
  172. yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
  173. yield* git(source, ["add", "."])
  174. yield* git(source, ["commit", "-m", "add readme"])
  175. yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
  176. yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
  177. yield* githubBase(
  178. `file://${remoteRoot}/`,
  179. provideTmpdirInstance(
  180. (_dir) =>
  181. Effect.gen(function* () {
  182. const reference = yield* Reference.Service
  183. yield* reference.init()
  184. yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
  185. }),
  186. {
  187. config: {
  188. reference: {
  189. docs: "opencode-reference-refresh/repo",
  190. },
  191. },
  192. },
  193. ),
  194. )
  195. const branch = yield* git(source, ["branch", "--show-current"])
  196. yield* git(source, ["remote", "add", "origin", remoteRepo])
  197. yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
  198. yield* git(source, ["add", "."])
  199. yield* git(source, ["commit", "-m", "update readme"])
  200. yield* git(source, ["push", "origin", `${branch}:${branch}`])
  201. yield* githubBase(
  202. `file://${remoteRoot}/`,
  203. provideTmpdirInstance(
  204. (_dir) =>
  205. Effect.gen(function* () {
  206. const reference = yield* Reference.Service
  207. yield* reference.init()
  208. yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
  209. }),
  210. {
  211. config: {
  212. reference: {
  213. docs: "opencode-reference-refresh/repo",
  214. },
  215. },
  216. },
  217. ),
  218. )
  219. }),
  220. ),
  221. )
  222. })