|
|
@@ -1,19 +1,20 @@
|
|
|
import { afterAll, afterEach, describe, test, expect } from "bun:test"
|
|
|
import path from "path"
|
|
|
import fs from "fs/promises"
|
|
|
-import { Effect, Layer, ManagedRuntime } from "effect"
|
|
|
+import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
|
|
import { EditTool } from "../../src/tool/edit"
|
|
|
-import { Instance } from "../../src/project/instance"
|
|
|
import { WithInstance } from "../../src/project/with-instance"
|
|
|
-import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
|
+import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture"
|
|
|
import { LSP } from "@/lsp/lsp"
|
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
|
import { Format } from "../../src/format"
|
|
|
import { Agent } from "../../src/agent/agent"
|
|
|
import { Bus } from "../../src/bus"
|
|
|
-import { BusEvent } from "../../src/bus/bus-event"
|
|
|
import { Truncate } from "@/tool/truncate"
|
|
|
import { SessionID, MessageID } from "../../src/session/schema"
|
|
|
+import * as Tool from "../../src/tool/tool"
|
|
|
+import { testEffect } from "../lib/effect"
|
|
|
+import { FileWatcher } from "../../src/file/watcher"
|
|
|
|
|
|
const ctx = {
|
|
|
sessionID: SessionID.make("ses_test-edit-session"),
|
|
|
@@ -30,17 +31,19 @@ afterEach(async () => {
|
|
|
await disposeAllInstances()
|
|
|
})
|
|
|
|
|
|
-const runtime = ManagedRuntime.make(
|
|
|
- Layer.mergeAll(
|
|
|
- LSP.defaultLayer,
|
|
|
- AppFileSystem.defaultLayer,
|
|
|
- Format.defaultLayer,
|
|
|
- Bus.layer,
|
|
|
- Truncate.defaultLayer,
|
|
|
- Agent.defaultLayer,
|
|
|
- ),
|
|
|
+const layer = Layer.mergeAll(
|
|
|
+ LSP.defaultLayer,
|
|
|
+ AppFileSystem.defaultLayer,
|
|
|
+ Format.defaultLayer,
|
|
|
+ Bus.layer,
|
|
|
+ Truncate.defaultLayer,
|
|
|
+ Agent.defaultLayer,
|
|
|
)
|
|
|
|
|
|
+const it = testEffect(layer)
|
|
|
+
|
|
|
+const runtime = ManagedRuntime.make(layer)
|
|
|
+
|
|
|
afterAll(async () => {
|
|
|
await runtime.dispose()
|
|
|
})
|
|
|
@@ -53,464 +56,258 @@ const resolve = () =>
|
|
|
}),
|
|
|
)
|
|
|
|
|
|
-const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
|
|
|
- runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
|
|
|
-
|
|
|
-async function onceBus<D extends BusEvent.Definition>(def: D) {
|
|
|
- const result = Promise.withResolvers<void>()
|
|
|
- const unsub = await subscribeBus(def, () => {
|
|
|
- unsub()
|
|
|
- result.resolve()
|
|
|
- })
|
|
|
- return {
|
|
|
- wait: result.promise,
|
|
|
- unsub,
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-describe("tool.edit", () => {
|
|
|
- describe("creating new files", () => {
|
|
|
- test("creates new file when oldString is empty", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "newfile.txt")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const result = await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "",
|
|
|
- newString: "new content",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- expect(result.metadata.diff).toContain("new content")
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("new content")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves BOM when oldString is empty on existing files", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "existing.cs")
|
|
|
- const bom = String.fromCharCode(0xfeff)
|
|
|
- await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")
|
|
|
+const init = Effect.fn("EditToolTest.init")(function* () {
|
|
|
+ const info = yield* EditTool
|
|
|
+ return yield* info.init()
|
|
|
+})
|
|
|
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const result = await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "",
|
|
|
- newString: "using Up;\n",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
+const run = Effect.fn("EditToolTest.run")(function* (
|
|
|
+ args: Tool.InferParameters<typeof EditTool>,
|
|
|
+ next: Tool.Context = ctx,
|
|
|
+) {
|
|
|
+ const tool = yield* init()
|
|
|
+ return yield* tool.execute(args, next)
|
|
|
+})
|
|
|
|
|
|
- expect(result.metadata.diff).toContain("-using System;")
|
|
|
- expect(result.metadata.diff).toContain("+using Up;")
|
|
|
+const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters<typeof EditTool>) {
|
|
|
+ const exit = yield* run(args).pipe(Effect.exit)
|
|
|
+ if (Exit.isFailure(exit)) {
|
|
|
+ const err = Cause.squash(exit.cause)
|
|
|
+ return err instanceof Error ? err : new Error(String(err))
|
|
|
+ }
|
|
|
+ throw new Error("expected edit to fail")
|
|
|
+})
|
|
|
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content.charCodeAt(0)).toBe(0xfeff)
|
|
|
- expect(content.slice(1)).toBe("using Up;\n")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) {
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
+ yield* fs.writeWithDirs(p, content)
|
|
|
+})
|
|
|
|
|
|
- test("creates new file with nested directories", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
|
|
|
+const load = Effect.fn("EditToolTest.load")(function* (p: string) {
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
+ return yield* fs.readFileString(p)
|
|
|
+})
|
|
|
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "",
|
|
|
- newString: "nested file",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
+const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) {
|
|
|
+ return yield* Effect.promise(() => fs.readFile(p, "utf-8"))
|
|
|
+})
|
|
|
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("nested file")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) {
|
|
|
+ const fs = yield* AppFileSystem.Service
|
|
|
+ yield* fs.makeDirectory(p)
|
|
|
+})
|
|
|
|
|
|
- test("emits add event for new files", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "new.txt")
|
|
|
+const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) {
|
|
|
+ const bus = yield* Bus.Service
|
|
|
+ const deferred = yield* Deferred.make<void>()
|
|
|
+ const unsub = yield* bus.subscribeCallback(def, () => Effect.runSync(Deferred.succeed(deferred, undefined)))
|
|
|
+ yield* Effect.addFinalizer(() => Effect.sync(unsub))
|
|
|
+ return deferred
|
|
|
+})
|
|
|
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const { FileWatcher } = await import("../../src/file/watcher")
|
|
|
-
|
|
|
- const updated = await onceBus(FileWatcher.Event.Updated)
|
|
|
-
|
|
|
- try {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "",
|
|
|
- newString: "content",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- await updated.wait
|
|
|
- } finally {
|
|
|
- updated.unsub()
|
|
|
- }
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+describe("tool.edit", () => {
|
|
|
+ describe("creating new files", () => {
|
|
|
+ it.instance("creates new file when oldString is empty", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "newfile.txt")
|
|
|
+ const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" })
|
|
|
+
|
|
|
+ expect(result.metadata.diff).toContain("new content")
|
|
|
+ expect(yield* load(filepath)).toBe("new content")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves BOM when oldString is empty on existing files", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "existing.cs")
|
|
|
+ const bom = String.fromCharCode(0xfeff)
|
|
|
+ yield* put(filepath, `${bom}using System;\n`)
|
|
|
+
|
|
|
+ const result = yield* run({ filePath: filepath, oldString: "", newString: "using Up;\n" })
|
|
|
+
|
|
|
+ expect(result.metadata.diff).toContain("-using System;")
|
|
|
+ expect(result.metadata.diff).toContain("+using Up;")
|
|
|
+
|
|
|
+ const content = yield* loadRaw(filepath)
|
|
|
+ expect(content.charCodeAt(0)).toBe(0xfeff)
|
|
|
+ expect(content.slice(1)).toBe("using Up;\n")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("creates new file with nested directories", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "nested", "dir", "file.txt")
|
|
|
+
|
|
|
+ yield* run({ filePath: filepath, oldString: "", newString: "nested file" })
|
|
|
+
|
|
|
+ expect(yield* load(filepath)).toBe("nested file")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("emits add event for new files", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const updated = yield* onceBus(FileWatcher.Event.Updated)
|
|
|
+
|
|
|
+ yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" })
|
|
|
+ yield* Deferred.await(updated)
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
describe("editing existing files", () => {
|
|
|
- test("replaces text in existing file", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "existing.txt")
|
|
|
- await fs.writeFile(filepath, "old content here", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const result = await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "old content",
|
|
|
- newString: "new content",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- expect(result.output).toContain("Edit applied successfully")
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("new content here")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("replaces the first visible line in BOM files", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "existing.cs")
|
|
|
- const bom = String.fromCharCode(0xfeff)
|
|
|
- await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const result = await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "using System;",
|
|
|
- newString: "using Up;",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- expect(result.metadata.diff).toContain("-using System;")
|
|
|
- expect(result.metadata.diff).toContain("+using Up;")
|
|
|
- expect(result.metadata.diff).not.toContain(bom)
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content.charCodeAt(0)).toBe(0xfeff)
|
|
|
- expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("throws error when file does not exist", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "nonexistent.txt")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await expect(
|
|
|
- Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "old",
|
|
|
- newString: "new",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ).rejects.toThrow("not found")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("throws error when oldString equals newString", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await expect(
|
|
|
- Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "same",
|
|
|
- newString: "same",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ).rejects.toThrow("identical")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("throws error when oldString not found in file", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "actual content", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await expect(
|
|
|
- Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "not in file",
|
|
|
- newString: "replacement",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ).rejects.toThrow()
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("replaces all occurrences with replaceAll option", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "foo",
|
|
|
- newString: "qux",
|
|
|
- replaceAll: true,
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("qux bar qux baz qux")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("emits change event for existing files", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "original", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const { FileWatcher } = await import("../../src/file/watcher")
|
|
|
-
|
|
|
- const updated = await onceBus(FileWatcher.Event.Updated)
|
|
|
-
|
|
|
- try {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "original",
|
|
|
- newString: "modified",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- await updated.wait
|
|
|
- } finally {
|
|
|
- updated.unsub()
|
|
|
- }
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ it.instance("replaces text in existing file", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "existing.txt")
|
|
|
+ yield* put(filepath, "old content here")
|
|
|
+
|
|
|
+ const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" })
|
|
|
+
|
|
|
+ expect(result.output).toContain("Edit applied successfully")
|
|
|
+ expect(yield* load(filepath)).toBe("new content here")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("replaces the first visible line in BOM files", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "existing.cs")
|
|
|
+ const bom = String.fromCharCode(0xfeff)
|
|
|
+ yield* put(filepath, `${bom}using System;\nclass Test {}\n`)
|
|
|
+
|
|
|
+ const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" })
|
|
|
+
|
|
|
+ expect(result.metadata.diff).toContain("-using System;")
|
|
|
+ expect(result.metadata.diff).toContain("+using Up;")
|
|
|
+ expect(result.metadata.diff).not.toContain(bom)
|
|
|
+
|
|
|
+ const content = yield* loadRaw(filepath)
|
|
|
+ expect(content.charCodeAt(0)).toBe(0xfeff)
|
|
|
+ expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("throws error when file does not exist", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ expect(
|
|
|
+ (yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" }))
|
|
|
+ .message,
|
|
|
+ ).toContain("not found")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("throws error when oldString equals newString", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "content")
|
|
|
+
|
|
|
+ expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain(
|
|
|
+ "identical",
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("throws error when oldString not found in file", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "actual content")
|
|
|
+
|
|
|
+ expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf(
|
|
|
+ Error,
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("replaces all occurrences with replaceAll option", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "foo bar foo baz foo")
|
|
|
+
|
|
|
+ yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true })
|
|
|
+
|
|
|
+ expect(yield* load(filepath)).toBe("qux bar qux baz qux")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("emits change event for existing files", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "original")
|
|
|
+ const updated = yield* onceBus(FileWatcher.Event.Updated)
|
|
|
+
|
|
|
+ yield* run({ filePath: filepath, oldString: "original", newString: "modified" })
|
|
|
+ yield* Deferred.await(updated)
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
describe("edge cases", () => {
|
|
|
- test("handles multiline replacements", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "line2",
|
|
|
- newString: "new line 2\nextra line",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("handles CRLF line endings", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "old",
|
|
|
- newString: "new",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- const content = await fs.readFile(filepath, "utf-8")
|
|
|
- expect(content).toBe("line1\r\nnew\r\nline3")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("throws error when oldString equals newString", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "content", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await expect(
|
|
|
- Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "",
|
|
|
- newString: "",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ).rejects.toThrow("identical")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("throws error when path is directory", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const dirpath = path.join(tmp.path, "adir")
|
|
|
- await fs.mkdir(dirpath)
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- await expect(
|
|
|
- Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: dirpath,
|
|
|
- oldString: "old",
|
|
|
- newString: "new",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ).rejects.toThrow("directory")
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- test("tracks file diff statistics", async () => {
|
|
|
- await using tmp = await tmpdir()
|
|
|
- const filepath = path.join(tmp.path, "file.txt")
|
|
|
- await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
|
|
-
|
|
|
- await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const result = await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath: filepath,
|
|
|
- oldString: "line2",
|
|
|
- newString: "new line a\nnew line b",
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- expect(result.metadata.filediff).toBeDefined()
|
|
|
- expect(result.metadata.filediff.file).toBe(filepath)
|
|
|
- expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
|
|
- },
|
|
|
- })
|
|
|
- })
|
|
|
+ it.instance("handles multiline replacements", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "line1\nline2\nline3")
|
|
|
+
|
|
|
+ yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" })
|
|
|
+
|
|
|
+ expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("handles CRLF line endings", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "line1\r\nold\r\nline3")
|
|
|
+
|
|
|
+ yield* run({ filePath: filepath, oldString: "old", newString: "new" })
|
|
|
+
|
|
|
+ expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("throws error when oldString equals newString", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "content")
|
|
|
+
|
|
|
+ expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("throws error when path is directory", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const dirpath = path.join(test.directory, "adir")
|
|
|
+ yield* makeDirectory(dirpath)
|
|
|
+
|
|
|
+ expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory")
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("tracks file diff statistics", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filepath = path.join(test.directory, "file.txt")
|
|
|
+ yield* put(filepath, "line1\nline2\nline3")
|
|
|
+
|
|
|
+ const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" })
|
|
|
+
|
|
|
+ expect(result.metadata.filediff).toBeDefined()
|
|
|
+ expect(result.metadata.filediff.file).toBe(filepath)
|
|
|
+ expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
describe("line endings", () => {
|
|
|
@@ -552,149 +349,154 @@ describe("tool.edit", () => {
|
|
|
replaceAll?: boolean
|
|
|
}
|
|
|
|
|
|
- const apply = async (input: Input) => {
|
|
|
- await using tmp = await tmpdir({
|
|
|
- init: async (dir) => {
|
|
|
- await Bun.write(path.join(dir, "test.txt"), input.content)
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- return await WithInstance.provide({
|
|
|
- directory: tmp.path,
|
|
|
- fn: async () => {
|
|
|
- const edit = await resolve()
|
|
|
- const filePath = path.join(tmp.path, "test.txt")
|
|
|
- await Effect.runPromise(
|
|
|
- edit.execute(
|
|
|
- {
|
|
|
- filePath,
|
|
|
- oldString: input.oldString,
|
|
|
- newString: input.newString,
|
|
|
- replaceAll: input.replaceAll,
|
|
|
- },
|
|
|
- ctx,
|
|
|
- ),
|
|
|
- )
|
|
|
- return await Bun.file(filePath).text()
|
|
|
- },
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- test("preserves LF with LF multi-line strings", async () => {
|
|
|
- const content = normalize(old + "\n", "\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\n"),
|
|
|
- newString: normalize(next, "\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
- expectLf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves CRLF with CRLF multi-line strings", async () => {
|
|
|
- const content = normalize(old + "\n", "\r\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\r\n"),
|
|
|
- newString: normalize(next, "\r\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
- expectCrlf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves LF when old/new use CRLF", async () => {
|
|
|
- const content = normalize(old + "\n", "\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\r\n"),
|
|
|
- newString: normalize(next, "\r\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
- expectLf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves CRLF when old/new use LF", async () => {
|
|
|
- const content = normalize(old + "\n", "\r\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\n"),
|
|
|
- newString: normalize(next, "\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
- expectCrlf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves LF when newString uses CRLF", async () => {
|
|
|
- const content = normalize(old + "\n", "\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\n"),
|
|
|
- newString: normalize(next, "\r\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
- expectLf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves CRLF when newString uses LF", async () => {
|
|
|
- const content = normalize(old + "\n", "\r\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(old, "\r\n"),
|
|
|
- newString: normalize(next, "\n"),
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
- expectCrlf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("preserves LF with mixed old/new line endings", async () => {
|
|
|
- const content = normalize(old + "\n", "\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: "alpha\nbeta\r\ngamma",
|
|
|
- newString: "alpha\r\nbeta\nomega",
|
|
|
+ const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) {
|
|
|
+ const test = yield* TestInstance
|
|
|
+ const filePath = path.join(test.directory, "test.txt")
|
|
|
+ yield* put(filePath, input.content)
|
|
|
+ yield* run({
|
|
|
+ filePath,
|
|
|
+ oldString: input.oldString,
|
|
|
+ newString: input.newString,
|
|
|
+ replaceAll: input.replaceAll,
|
|
|
})
|
|
|
- expect(output).toBe(normalize(alt + "\n", "\n"))
|
|
|
- expectLf(output)
|
|
|
+ return yield* load(filePath)
|
|
|
})
|
|
|
|
|
|
- test("preserves CRLF with mixed old/new line endings", async () => {
|
|
|
- const content = normalize(old + "\n", "\r\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: "alpha\r\nbeta\ngamma",
|
|
|
- newString: "alpha\nbeta\r\nomega",
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(alt + "\n", "\r\n"))
|
|
|
- expectCrlf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("replaceAll preserves LF for multi-line blocks", async () => {
|
|
|
- const blockOld = "alpha\nbeta"
|
|
|
- const blockNew = "alpha\nbeta-updated"
|
|
|
- const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(blockOld, "\n"),
|
|
|
- newString: normalize(blockNew, "\n"),
|
|
|
- replaceAll: true,
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
|
|
|
- expectLf(output)
|
|
|
- })
|
|
|
-
|
|
|
- test("replaceAll preserves CRLF for multi-line blocks", async () => {
|
|
|
- const blockOld = "alpha\nbeta"
|
|
|
- const blockNew = "alpha\nbeta-updated"
|
|
|
- const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
|
|
|
- const output = await apply({
|
|
|
- content,
|
|
|
- oldString: normalize(blockOld, "\r\n"),
|
|
|
- newString: normalize(blockNew, "\r\n"),
|
|
|
- replaceAll: true,
|
|
|
- })
|
|
|
- expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
|
|
|
- expectCrlf(output)
|
|
|
- })
|
|
|
+ it.instance("preserves LF with LF multi-line strings", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\n"),
|
|
|
+ newString: normalize(next, "\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
+ expectLf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves CRLF with CRLF multi-line strings", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\r\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\r\n"),
|
|
|
+ newString: normalize(next, "\r\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
+ expectCrlf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves LF when old/new use CRLF", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\r\n"),
|
|
|
+ newString: normalize(next, "\r\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
+ expectLf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves CRLF when old/new use LF", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\r\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\n"),
|
|
|
+ newString: normalize(next, "\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
+ expectCrlf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves LF when newString uses CRLF", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\n"),
|
|
|
+ newString: normalize(next, "\r\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\n"))
|
|
|
+ expectLf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves CRLF when newString uses LF", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\r\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(old, "\r\n"),
|
|
|
+ newString: normalize(next, "\n"),
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(next + "\n", "\r\n"))
|
|
|
+ expectCrlf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves LF with mixed old/new line endings", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: "alpha\nbeta\r\ngamma",
|
|
|
+ newString: "alpha\r\nbeta\nomega",
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(alt + "\n", "\n"))
|
|
|
+ expectLf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("preserves CRLF with mixed old/new line endings", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const content = normalize(old + "\n", "\r\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: "alpha\r\nbeta\ngamma",
|
|
|
+ newString: "alpha\nbeta\r\nomega",
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(alt + "\n", "\r\n"))
|
|
|
+ expectCrlf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("replaceAll preserves LF for multi-line blocks", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const blockOld = "alpha\nbeta"
|
|
|
+ const blockNew = "alpha\nbeta-updated"
|
|
|
+ const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(blockOld, "\n"),
|
|
|
+ newString: normalize(blockNew, "\n"),
|
|
|
+ replaceAll: true,
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
|
|
|
+ expectLf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ it.instance("replaceAll preserves CRLF for multi-line blocks", () =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const blockOld = "alpha\nbeta"
|
|
|
+ const blockNew = "alpha\nbeta-updated"
|
|
|
+ const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
|
|
|
+ const output = yield* apply({
|
|
|
+ content,
|
|
|
+ oldString: normalize(blockOld, "\r\n"),
|
|
|
+ newString: normalize(blockNew, "\r\n"),
|
|
|
+ replaceAll: true,
|
|
|
+ })
|
|
|
+ expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
|
|
|
+ expectCrlf(output)
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
describe("concurrent editing", () => {
|