fixture.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { $ } from "bun"
  2. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  3. import * as fs from "fs/promises"
  4. import os from "os"
  5. import path from "path"
  6. import { Effect, Context, Layer } from "effect"
  7. import type * as PlatformError from "effect/PlatformError"
  8. import type * as Scope from "effect/Scope"
  9. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  10. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  11. import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
  12. import type { Config } from "@/config/config"
  13. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  14. import { InstanceRef } from "../../src/effect/instance-ref"
  15. import { InstanceBootstrap } from "../../src/project/bootstrap-service"
  16. import type { InstanceContext } from "../../src/project/instance-context"
  17. import { InstanceRuntime } from "../../src/project/instance-runtime"
  18. import { InstanceStore } from "../../src/project/instance-store"
  19. import { TestLLMServer } from "../lib/llm-server"
  20. const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
  21. export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [
  22. [InstanceStore.bootstrapNode, noopBootstrap],
  23. ])
  24. export async function provideTestInstance<R>(input: {
  25. directory: string
  26. init?: Effect.Effect<void>
  27. fn: (ctx: InstanceContext) => R
  28. }) {
  29. const ctx = await InstanceRuntime.load({ directory: input.directory })
  30. try {
  31. if (input.init) await Effect.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
  32. return await input.fn(ctx)
  33. } finally {
  34. await InstanceRuntime.disposeInstance(ctx)
  35. }
  36. }
  37. export async function withTestInstance<R>(input: { directory: string; fn: (ctx: InstanceContext) => R }) {
  38. return input.fn(await InstanceRuntime.load({ directory: input.directory }))
  39. }
  40. export async function reloadTestInstance(input: { directory: string }) {
  41. return InstanceRuntime.reloadInstance(input)
  42. }
  43. export async function disposeAllInstances() {
  44. await InstanceRuntime.disposeAllInstances()
  45. }
  46. // Strip null bytes from paths (defensive fix for CI environment issues)
  47. function sanitizePath(p: string): string {
  48. return p.replace(/\0/g, "")
  49. }
  50. function exists(dir: string) {
  51. return fs
  52. .stat(dir)
  53. .then(() => true)
  54. .catch(() => false)
  55. }
  56. function clean(dir: string) {
  57. return fs.rm(dir, {
  58. recursive: true,
  59. force: true,
  60. maxRetries: 5,
  61. retryDelay: 100,
  62. })
  63. }
  64. async function stop(dir: string) {
  65. if (!(await exists(dir))) return
  66. await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
  67. }
  68. type TmpDirOptions<T> = {
  69. git?: boolean
  70. config?: Partial<ConfigV1.Info>
  71. init?: (dir: string) => Promise<T>
  72. dispose?: (dir: string) => Promise<T>
  73. }
  74. export async function tmpdir<T>(options?: TmpDirOptions<T>) {
  75. const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
  76. await fs.mkdir(dirpath, { recursive: true })
  77. if (options?.git) {
  78. await $`git init`.cwd(dirpath).quiet()
  79. await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
  80. await $`git config commit.gpgsign false`.cwd(dirpath).quiet()
  81. await $`git config user.email "test@opencode.test"`.cwd(dirpath).quiet()
  82. await $`git config user.name "Test"`.cwd(dirpath).quiet()
  83. await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
  84. }
  85. if (options?.config) {
  86. await Bun.write(
  87. path.join(dirpath, "kirincode.json"),
  88. JSON.stringify({
  89. $schema: "https://kirincode.ai/config.json",
  90. ...options.config,
  91. }),
  92. )
  93. }
  94. const realpath = sanitizePath(await fs.realpath(dirpath))
  95. const extra = await options?.init?.(realpath)
  96. const result = {
  97. [Symbol.asyncDispose]: async () => {
  98. try {
  99. await options?.dispose?.(realpath)
  100. } finally {
  101. if (options?.git) await stop(realpath).catch(() => undefined)
  102. await clean(realpath).catch(() => undefined)
  103. }
  104. },
  105. path: realpath,
  106. extra: extra as T,
  107. }
  108. return result
  109. }
  110. /** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
  111. export function tmpdirScoped<E = never, R = never>(options?: {
  112. git?: boolean
  113. config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>)
  114. init?: (directory: string) => Effect.Effect<void, E, R>
  115. }) {
  116. return Effect.gen(function* () {
  117. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
  118. const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
  119. yield* Effect.promise(() => fs.mkdir(dirpath, { recursive: true }))
  120. const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath)))
  121. yield* Effect.addFinalizer(() =>
  122. Effect.promise(async () => {
  123. if (options?.git) await stop(dir).catch(() => undefined)
  124. await clean(dir).catch(() => undefined)
  125. }),
  126. )
  127. const git = (...args: string[]) =>
  128. spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
  129. if (options?.git) {
  130. yield* git("init")
  131. yield* git("config", "core.fsmonitor", "false")
  132. yield* git("config", "commit.gpgsign", "false")
  133. yield* git("config", "user.email", "test@opencode.test")
  134. yield* git("config", "user.name", "Test")
  135. yield* git("commit", "--allow-empty", "-m", `root commit ${dir}`)
  136. }
  137. if (options?.config) {
  138. const resolved = typeof options.config === "function" ? options.config() : options.config
  139. yield* Effect.promise(() =>
  140. fs.writeFile(
  141. path.join(dir, "kirincode.json"),
  142. JSON.stringify({ $schema: "https://kirincode.ai/config.json", ...resolved }),
  143. ),
  144. )
  145. }
  146. if (options?.init) yield* options.init(dir)
  147. return dir
  148. })
  149. }
  150. export const provideInstance =
  151. (directory: string) =>
  152. <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
  153. InstanceStore.Service.use((store) => store.provide({ directory }, self))
  154. export const provideInstanceEffect =
  155. (directory: string) =>
  156. <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | InstanceStore.Service> =>
  157. InstanceStore.Service.use((store) => store.provide({ directory }, self))
  158. export const reloadInstance = (input: InstanceStore.LoadInput) =>
  159. InstanceStore.Service.use((store) => store.reload(input))
  160. export const disposeAllInstancesEffect = InstanceStore.Service.use((store) => store.disposeAll())
  161. export function provideTmpdirInstance<A, E, R>(
  162. self: (path: string) => Effect.Effect<A, E, R>,
  163. options?: { git?: boolean; config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>) },
  164. ) {
  165. return Effect.gen(function* () {
  166. const path = yield* tmpdirScoped(options)
  167. return yield* self(path).pipe(provideInstance(path))
  168. }).pipe(Effect.provide(testInstanceStoreLayer))
  169. }
  170. export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
  171. export const requireInstance = Effect.gen(function* () {
  172. const instance = yield* InstanceRef
  173. if (!instance) return yield* Effect.die(new Error("missing test instance"))
  174. return instance
  175. })
  176. export const withTmpdirInstance =
  177. <E2 = never, R2 = never>(options?: {
  178. git?: boolean
  179. config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>)
  180. init?: (directory: string) => Effect.Effect<void, E2, R2>
  181. }) =>
  182. <A, E, R>(self: Effect.Effect<A, E, R>) =>
  183. Effect.gen(function* () {
  184. const directory = yield* tmpdirScoped(options)
  185. return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory))
  186. }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)))
  187. export function provideTmpdirServer<A, E, R>(
  188. self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
  189. options?: { git?: boolean; config?: (url: string) => Partial<ConfigV1.Info> },
  190. ): Effect.Effect<
  191. A,
  192. E | PlatformError.PlatformError,
  193. R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
  194. > {
  195. return Effect.gen(function* () {
  196. const llm = yield* TestLLMServer
  197. return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
  198. git: options?.git,
  199. config: options?.config?.(llm.url),
  200. })
  201. })
  202. }