| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- import fs from "fs/promises"
- import path from "path"
- import { describe, expect } from "bun:test"
- import { Deferred, Effect, Fiber, Layer } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { FileMutation } from "@opencode-ai/core/file-mutation"
- import { Environment } from "@opencode-ai/core/environment"
- import { Location } from "@opencode-ai/core/location"
- import { LocationMutation } from "@opencode-ai/core/location-mutation"
- import { AbsolutePath } from "@opencode-ai/core/schema"
- import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
- import { location } from "./fixture/location"
- import { tmpdir } from "./fixture/tmpdir"
- import { it } from "./lib/effect"
- function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
- const activeLocation = Layer.succeed(
- Location.Service,
- Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
- )
- return Effect.provide(
- AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
- [Location.node, activeLocation],
- [Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
- ]),
- )
- }
- function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
- return Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
- }
- describe("FileMutation", () => {
- it.live("writes an existing internal file and returns a stable result", () =>
- withTmp((directory) =>
- Effect.gen(function* () {
- const targetPath = path.join(directory, "hello.txt")
- yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
- const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
- expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
- operation: "write",
- target: target.absolute,
- resource: "hello.txt",
- existed: true,
- })
- expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
- }).pipe(provide(directory)),
- ),
- )
- it.live("writes a prospective internal file and creates parent directories", () =>
- withTmp((directory) =>
- Effect.gen(function* () {
- const target = yield* (yield* LocationMutation.Service).resolve({
- path: path.join("src", "nested", "hello.txt"),
- })
- const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
- expect(result).toEqual({
- operation: "write",
- target: target.absolute,
- resource: "src/nested/hello.txt",
- existed: false,
- })
- expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
- }).pipe(provide(directory)),
- ),
- )
- it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
- withTmp((directory) =>
- Effect.gen(function* () {
- const preservedPath = path.join(directory, "preserved.txt")
- yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
- const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
- const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
- const files = yield* FileMutation.Service
- yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
- yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
- expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
- expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
- }).pipe(provide(directory)),
- ),
- )
- it.live("writes an explicitly resolved external target", () =>
- withTmp((directory) =>
- withTmp((outside) =>
- Effect.gen(function* () {
- const targetPath = path.join(outside, "external.txt")
- const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
- const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
- expect(result).toEqual({
- operation: "write",
- target: target.absolute,
- resource: target.resource,
- existed: false,
- })
- expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
- }).pipe(provide(directory)),
- ),
- ),
- )
- it.live("shares transaction locks across Location service instances", () =>
- withTmp((directory) =>
- Effect.gen(function* () {
- const firstStarted = yield* Deferred.make<void>()
- const releaseFirst = yield* Deferred.make<void>()
- const secondStarted = yield* Deferred.make<void>()
- const target = path.join(directory, "shared.txt")
- const first = yield* Effect.gen(function* () {
- const files = yield* FileMutation.Service
- yield* files.withLock([target])(
- Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
- )
- }).pipe(provide(directory), Effect.forkChild)
- yield* Deferred.await(firstStarted)
- const second = yield* Effect.gen(function* () {
- const files = yield* FileMutation.Service
- yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
- }).pipe(provide(directory), Effect.forkChild)
- yield* Effect.yieldNow
- expect(yield* Deferred.isDone(secondStarted)).toBe(false)
- yield* Deferred.succeed(releaseFirst, undefined)
- yield* Deferred.await(secondStarted)
- yield* Fiber.join(first)
- yield* Fiber.join(second)
- }),
- ),
- )
- it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
- withTmp((directory) =>
- Effect.gen(function* () {
- const firstStarted = yield* Deferred.make<void>()
- const releaseFirst = yield* Deferred.make<void>()
- const secondFinished = yield* Deferred.make<void>()
- const files = yield* FileMutation.Service
- const first = yield* files
- .withLock([path.join(directory, "first.txt")])(
- Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
- )
- .pipe(Effect.forkChild)
- yield* Deferred.await(firstStarted)
- yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
- expect(yield* Deferred.isDone(secondFinished)).toBe(true)
- yield* Deferred.succeed(releaseFirst, undefined)
- yield* Fiber.join(first)
- }).pipe(provide(directory)),
- ),
- )
- })
|