fixture.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { $ } from "bun"
  2. import * as fs from "fs/promises"
  3. import os from "os"
  4. import path from "path"
  5. import { Effect, ServiceMap } from "effect"
  6. import type * as PlatformError from "effect/PlatformError"
  7. import type * as Scope from "effect/Scope"
  8. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  9. import type { Config } from "../../src/config/config"
  10. import { InstanceRef } from "../../src/effect/instance-ref"
  11. import { Instance } from "../../src/project/instance"
  12. import { TestLLMServer } from "../lib/llm-server"
  13. // Strip null bytes from paths (defensive fix for CI environment issues)
  14. function sanitizePath(p: string): string {
  15. return p.replace(/\0/g, "")
  16. }
  17. function exists(dir: string) {
  18. return fs
  19. .stat(dir)
  20. .then(() => true)
  21. .catch(() => false)
  22. }
  23. function clean(dir: string) {
  24. return fs.rm(dir, {
  25. recursive: true,
  26. force: true,
  27. maxRetries: 5,
  28. retryDelay: 100,
  29. })
  30. }
  31. async function stop(dir: string) {
  32. if (!(await exists(dir))) return
  33. await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
  34. }
  35. type TmpDirOptions<T> = {
  36. git?: boolean
  37. config?: Partial<Config.Info>
  38. init?: (dir: string) => Promise<T>
  39. dispose?: (dir: string) => Promise<T>
  40. }
  41. export async function tmpdir<T>(options?: TmpDirOptions<T>) {
  42. const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
  43. await fs.mkdir(dirpath, { recursive: true })
  44. if (options?.git) {
  45. await $`git init`.cwd(dirpath).quiet()
  46. await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
  47. await $`git config commit.gpgsign false`.cwd(dirpath).quiet()
  48. await $`git config user.email "[email protected]"`.cwd(dirpath).quiet()
  49. await $`git config user.name "Test"`.cwd(dirpath).quiet()
  50. await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
  51. }
  52. if (options?.config) {
  53. await Bun.write(
  54. path.join(dirpath, "opencode.json"),
  55. JSON.stringify({
  56. $schema: "https://app.kilo.ai/config.json",
  57. ...options.config,
  58. }),
  59. )
  60. }
  61. const realpath = sanitizePath(await fs.realpath(dirpath))
  62. const extra = await options?.init?.(realpath)
  63. const result = {
  64. [Symbol.asyncDispose]: async () => {
  65. try {
  66. await options?.dispose?.(realpath)
  67. } finally {
  68. if (options?.git) await stop(realpath).catch(() => undefined)
  69. await clean(realpath).catch(() => undefined)
  70. }
  71. },
  72. path: realpath,
  73. extra: extra as T,
  74. }
  75. return result
  76. }
  77. /** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
  78. export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
  79. return Effect.gen(function* () {
  80. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
  81. const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
  82. yield* Effect.promise(() => fs.mkdir(dirpath, { recursive: true }))
  83. const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath)))
  84. yield* Effect.addFinalizer(() =>
  85. Effect.promise(async () => {
  86. if (options?.git) await stop(dir).catch(() => undefined)
  87. await clean(dir).catch(() => undefined)
  88. }),
  89. )
  90. const git = (...args: string[]) =>
  91. spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
  92. if (options?.git) {
  93. yield* git("init")
  94. yield* git("config", "core.fsmonitor", "false")
  95. yield* git("config", "commit.gpgsign", "false")
  96. yield* git("config", "user.email", "[email protected]")
  97. yield* git("config", "user.name", "Test")
  98. yield* git("commit", "--allow-empty", "-m", "root commit")
  99. }
  100. if (options?.config) {
  101. yield* Effect.promise(() =>
  102. fs.writeFile(
  103. path.join(dir, "opencode.json"),
  104. JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
  105. ),
  106. )
  107. }
  108. return dir
  109. })
  110. }
  111. export const provideInstance =
  112. (directory: string) =>
  113. <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
  114. Effect.servicesWith((services: ServiceMap.ServiceMap<R>) =>
  115. Effect.promise<A>(async () =>
  116. Instance.provide({
  117. directory,
  118. fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
  119. }),
  120. ),
  121. )
  122. export function provideTmpdirInstance<A, E, R>(
  123. self: (path: string) => Effect.Effect<A, E, R>,
  124. options?: { git?: boolean; config?: Partial<Config.Info> },
  125. ) {
  126. return Effect.gen(function* () {
  127. const path = yield* tmpdirScoped(options)
  128. let provided = false
  129. yield* Effect.addFinalizer(() =>
  130. provided
  131. ? Effect.promise(() =>
  132. Instance.provide({
  133. directory: path,
  134. fn: () => Instance.dispose(),
  135. }),
  136. ).pipe(Effect.ignore)
  137. : Effect.void,
  138. )
  139. provided = true
  140. return yield* self(path).pipe(provideInstance(path))
  141. })
  142. }
  143. export function provideTmpdirServer<A, E, R>(
  144. self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
  145. options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
  146. ): Effect.Effect<
  147. A,
  148. E | PlatformError.PlatformError,
  149. R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
  150. > {
  151. return Effect.gen(function* () {
  152. const llm = yield* TestLLMServer
  153. return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
  154. git: options?.git,
  155. config: options?.config?.(llm.url),
  156. })
  157. })
  158. }