fixture.ts 4.2 KB

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