project-reference.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { describe, expect } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { Deferred, Effect, Layer, Schema } from "effect"
  5. import { Config } from "@opencode-ai/core/config"
  6. import { ConfigReference } from "@opencode-ai/core/config/reference"
  7. import { FSUtil } from "@opencode-ai/core/fs-util"
  8. import { Flag } from "@opencode-ai/core/flag/flag"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { ProjectReference } from "@opencode-ai/core/project-reference"
  12. import { Repository } from "@opencode-ai/core/repository"
  13. import { RepositoryCache } from "@opencode-ai/core/repository-cache"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { location } from "./fixture/location"
  16. import { tmpdir } from "./fixture/tmpdir"
  17. import { it } from "./lib/effect"
  18. describe("ProjectReference", () => {
  19. it.live("uses the broad experimental flag unless references are explicitly configured", () =>
  20. withEnv(
  21. { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined },
  22. Effect.sync(() => {
  23. expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
  24. }),
  25. ).pipe(
  26. Effect.flatMap(() =>
  27. withEnv(
  28. { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
  29. Effect.sync(() => {
  30. expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
  31. }),
  32. ),
  33. ),
  34. ),
  35. )
  36. it.live("normalizes aliases and resolves relative local paths from the project root", () =>
  37. withTmp((tmp) =>
  38. Effect.gen(function* () {
  39. const project = path.join(tmp.path, "project")
  40. const nested = path.join(project, "packages", "app")
  41. yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
  42. const references = ProjectReference.resolveAll({
  43. references: ConfigReference.normalize({
  44. docs: { path: "./docs" },
  45. home: "~/notes",
  46. sdk: { repository: "owner/repo", branch: "main" },
  47. shorthand: "owner/other",
  48. invalid: "not-a-repo",
  49. "bad/name": "owner/repo",
  50. }),
  51. directory: project,
  52. home: path.join(tmp.path, "home"),
  53. repos: path.join(tmp.path, "repos"),
  54. })
  55. expect(references).toMatchObject([
  56. { name: "docs", kind: "local", path: path.join(project, "docs") },
  57. { name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
  58. { name: "sdk", kind: "git", branch: "main" },
  59. { name: "shorthand", kind: "git" },
  60. { name: "invalid", kind: "invalid", repository: "not-a-repo" },
  61. { name: "bad/name", kind: "invalid" },
  62. ])
  63. }),
  64. ),
  65. )
  66. it.live("marks same-cache references with different branches invalid", () =>
  67. Effect.sync(() => {
  68. const references = ProjectReference.resolveAll({
  69. references: ConfigReference.normalize({
  70. main: { repository: "owner/repo", branch: "main" },
  71. dev: { repository: "github.com/owner/repo", branch: "dev" },
  72. alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
  73. }),
  74. directory: "/project",
  75. home: "/home",
  76. repos: "/repos",
  77. })
  78. expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
  79. expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
  80. }),
  81. )
  82. it.live("merges config aliases and exposes mention and managed-path operations", () =>
  83. withoutReferences(
  84. withTmp((tmp) => {
  85. const calls: RepositoryCache.EnsureInput[] = []
  86. const project = path.join(tmp.path, "project")
  87. const nested = path.join(project, "packages", "app")
  88. const docs = path.join(project, "docs")
  89. const repos = path.join(tmp.path, "repos")
  90. return Effect.gen(function* () {
  91. yield* Effect.promise(async () => {
  92. await fs.mkdir(nested, { recursive: true })
  93. await fs.mkdir(docs)
  94. await fs.writeFile(path.join(docs, "README.md"), "docs")
  95. })
  96. yield* withReferences(
  97. Effect.gen(function* () {
  98. const references = yield* ProjectReference.Service
  99. const git = path.join(repos, "github.com", "owner", "repo")
  100. expect(yield* references.list()).toMatchObject([
  101. { name: "docs", kind: "local", path: docs },
  102. { name: "sdk", kind: "git", path: git },
  103. ])
  104. expect(yield* references.resolveMention("docs/README.md")).toMatchObject({
  105. name: "docs",
  106. kind: "reference",
  107. target: "README.md",
  108. path: path.join(docs, "README.md"),
  109. })
  110. expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({
  111. name: "docs",
  112. kind: "missing",
  113. })
  114. expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({
  115. name: "docs",
  116. kind: "invalid",
  117. })
  118. expect(yield* references.resolveMention("unknown")).toBeUndefined()
  119. expect(yield* references.resolveMention("sdk")).toMatchObject({
  120. name: "sdk",
  121. kind: "reference",
  122. path: git,
  123. })
  124. expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true)
  125. expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false)
  126. yield* references.ensurePath()
  127. expect(calls).toHaveLength(1)
  128. }).pipe(
  129. Effect.provide(
  130. testLayer({
  131. directory: nested,
  132. project,
  133. repos,
  134. documents: [
  135. document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
  136. document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }),
  137. ],
  138. ensure: (input) => Effect.sync(() => result(repos, calls, input)),
  139. }),
  140. ),
  141. ),
  142. )
  143. })
  144. }),
  145. ),
  146. )
  147. it.live("is inert while the runtime flag is disabled", () =>
  148. withoutReferences(
  149. withTmp((tmp) => {
  150. const calls: RepositoryCache.EnsureInput[] = []
  151. return Effect.gen(function* () {
  152. const references = yield* ProjectReference.Service
  153. expect(yield* references.list()).toEqual([])
  154. expect(yield* references.get("sdk")).toBeUndefined()
  155. expect(yield* references.resolveMention("sdk")).toBeUndefined()
  156. expect(
  157. yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")),
  158. ).toBe(false)
  159. yield* references.ensurePath()
  160. expect(calls).toEqual([])
  161. }).pipe(
  162. Effect.provide(
  163. testLayer({
  164. directory: tmp.path,
  165. project: tmp.path,
  166. repos: path.join(tmp.path, "repos"),
  167. documents: [document({ sdk: "owner/repo" })],
  168. ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)),
  169. }),
  170. ),
  171. )
  172. }),
  173. ),
  174. )
  175. it.live("starts Git materialization in the background without blocking the location layer", () =>
  176. withTmp((tmp) =>
  177. Effect.gen(function* () {
  178. const started = yield* Deferred.make<void>()
  179. yield* withReferences(
  180. Effect.gen(function* () {
  181. expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1)
  182. yield* Deferred.await(started).pipe(
  183. Effect.timeoutOrElse({
  184. duration: "1 second",
  185. orElse: () => Effect.die(new Error("refresh did not start")),
  186. }),
  187. )
  188. }).pipe(
  189. Effect.provide(
  190. testLayer({
  191. directory: tmp.path,
  192. project: tmp.path,
  193. repos: path.join(tmp.path, "repos"),
  194. documents: [document({ sdk: "owner/repo" })],
  195. ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
  196. }),
  197. ),
  198. ),
  199. )
  200. }),
  201. ),
  202. )
  203. })
  204. function document(references: ConfigReference.Info) {
  205. return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
  206. }
  207. function result(
  208. repos: string,
  209. calls: RepositoryCache.EnsureInput[],
  210. input: RepositoryCache.EnsureInput,
  211. ): RepositoryCache.Result {
  212. calls.push(input)
  213. return {
  214. repository: input.reference.label,
  215. host: input.reference.host,
  216. remote: input.reference.remote,
  217. localPath: Repository.cachePath(repos, input.reference),
  218. status: "cached",
  219. branch: input.branch,
  220. }
  221. }
  222. function testLayer(input: {
  223. directory: string
  224. project: string
  225. repos: string
  226. documents: Config.Document[]
  227. ensure: RepositoryCache.Interface["ensure"]
  228. }) {
  229. return ProjectReference.layer.pipe(
  230. Layer.provide(
  231. Layer.mergeAll(
  232. FSUtil.defaultLayer,
  233. Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
  234. Layer.succeed(
  235. Location.Service,
  236. Location.Service.of(
  237. location(
  238. { directory: AbsolutePath.make(input.directory) },
  239. { projectDirectory: AbsolutePath.make(input.project) },
  240. ),
  241. ),
  242. ),
  243. Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
  244. Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
  245. ),
  246. ),
  247. )
  248. }
  249. function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
  250. return Effect.acquireUseRelease(
  251. Effect.promise(() => tmpdir()),
  252. body,
  253. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  254. )
  255. }
  256. function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
  257. return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
  258. }
  259. function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
  260. return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
  261. }
  262. function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
  263. return Effect.acquireUseRelease(
  264. Effect.sync(() => {
  265. const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
  266. for (const [key, value] of Object.entries(env)) {
  267. if (value === undefined) delete process.env[key]
  268. else process.env[key] = value
  269. }
  270. return previous
  271. }),
  272. () => body,
  273. (previous) =>
  274. Effect.sync(() => {
  275. for (const [key, value] of Object.entries(previous)) {
  276. if (value === undefined) delete process.env[key]
  277. else process.env[key] = value
  278. }
  279. }),
  280. )
  281. }