diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 7a7508d2f61..0d5c8d868d7 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -22,7 +22,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform = return Effect.provide( AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ [Location.node, activeLocation], - [Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)], + [Environment.node, transformEnvironmentFiles(transformFiles)], ]), ) } diff --git a/packages/core/test/fixture/environment.ts b/packages/core/test/fixture/environment.ts index d9a341a6265..3f43043b1c5 100644 --- a/packages/core/test/fixture/environment.ts +++ b/packages/core/test/fixture/environment.ts @@ -1,6 +1,4 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Environment } from "@opencode-ai/core/environment/index" -import { Location } from "@opencode-ai/core/location" import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Effect, Layer } from "effect" @@ -40,10 +38,8 @@ export const recordingEnvironmentLayer = (spawns: Array) = export type EnvironmentFilesTransform = (files: Environment.Files) => Partial -export function transformEnvironmentFiles( - location: Layer.Layer, - transform: EnvironmentFilesTransform = () => ({}), -) { +// Wrap real host filesystem operations without constructing workspace services. +export function transformEnvironmentFiles(transform: EnvironmentFilesTransform = () => ({})) { return Layer.effect( Environment.Service, Effect.gen(function* () { @@ -53,5 +49,5 @@ export function transformEnvironmentFiles( files: { ...current.files, ...transform(current.files) }, }) }), - ).pipe(Layer.provide(AppNodeBuilder.build(Environment.node, [[Location.node, location]]))) + ).pipe(Layer.provide(hostEnvironmentLayer)) } diff --git a/packages/core/test/fixture/tmpdir.ts b/packages/core/test/fixture/tmpdir.ts index 58cfc1b40f5..36781de510c 100644 --- a/packages/core/test/fixture/tmpdir.ts +++ b/packages/core/test/fixture/tmpdir.ts @@ -1,6 +1,7 @@ import fs from "fs/promises" import { tmpdir as osTmpdir } from "os" import path from "path" +import { Effect } from "effect" export const tmpdir = async (prefix = "opencode-core-test-") => { const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix))) @@ -12,6 +13,13 @@ export const tmpdir = async (prefix = "opencode-core-test-") => { } } +export const withTempDir = (body: (tmp: Awaited>) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + body, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + async function remove(dir: string, retries = 30): Promise { try { await fs.rm(dir, { recursive: true, force: true }) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 80b5e4ee806..fa0b3e22a27 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -16,7 +16,7 @@ import { Tool } from "@opencode-ai/core/tool" import { EditTool } from "@opencode-ai/core/tool/plugin/edit" import { transformEnvironmentFiles } from "./fixture/environment" import { location } from "./fixture/location" -import { tmpdir } from "./fixture/tmpdir" +import { tmpdir, withTempDir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" import { permissionLayer } from "./lib/permission" @@ -37,77 +37,78 @@ const editToolNode = makeLocationNode({ }) const sessionID = Session.ID.make("ses_edit_tool_test") -const assertions: Permission.AssertInput[] = [] -const writes: string[] = [] -let reads = 0 -let denyAction: string | undefined -let afterRead = (_target: string, _content: Uint8Array): Effect.Effect => Effect.void -let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) +const makeEditFixture = () => { + const fixture: { + assertions: Permission.AssertInput[] + writes: string[] + reads: number + denyAction?: string + afterRead: () => Effect.Effect + formatFile: (target: string) => Effect.Effect + } = { + assertions: [], + writes: [], + reads: 0, + afterRead: () => Effect.void, + formatFile: () => Effect.succeed(false), + } -const permission = permissionLayer({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, + const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => fixture.assertions.push(input)).pipe( + Effect.andThen( + input.action === fixture.denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), - ), -}) + }) -const formatter = Layer.mock(Formatter.Service, { - file: (target) => formatFile(target), -}) + const formatter = Layer.mock(Formatter.Service, { + file: (target) => fixture.formatFile(target), + }) -const reset = () => { - assertions.length = 0 - writes.length = 0 - reads = 0 - denyAction = undefined - afterRead = () => Effect.void - formatFile = () => Effect.succeed(false) + return Object.assign(fixture, { permission, formatter }) } -const withTool = (directory: string, body: (registry: Tool.Interface) => Effect.Effect) => { +const withTool = ( + directory: string, + fixture: ReturnType, + body: (registry: Tool.Interface) => Effect.Effect, +) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) return Effect.gen(function* () { - return yield* body(yield* Tool.Service) + const registry = yield* Tool.Service + return yield* body(registry) }).pipe( Effect.provide( - AppNodeBuilder.build( - LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), + AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [ [ - [ - Environment.node, - transformEnvironmentFiles(activeLocation, (files) => ({ - read: (target, range) => - files - .read(target, range) - .pipe( - Effect.tap((result) => - Effect.sync(() => reads++).pipe( - Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes))), - ), - ), - ), - write: (target, content) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))), - })), - ], - [Location.node, activeLocation], - [Formatter.node, formatter], - [Permission.node, permission], + Environment.node, + transformEnvironmentFiles((files) => ({ + read: (target, range) => + files + .read(target, range) + .pipe( + Effect.tap(() => Effect.sync(() => fixture.reads++).pipe(Effect.andThen(() => fixture.afterRead()))), + ), + write: (target, content) => + Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), + })), ], - ), + [Location.node, activeLocation], + [Formatter.node, fixture.formatter], + [Permission.node, fixture.permission], + ]), ), ) } @@ -122,135 +123,125 @@ const it = testEffect(Layer.empty) describe("EditTool", () => { it.live("registers and replaces relative exact text through FileMutation once", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "hello.txt") - return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"]) - expect( - (yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map( - (tool) => tool.name, - ), - ).toEqual(["execute"]) - const settled = yield* executeTool( - registry, - call({ path: "hello.txt", oldString: "before", newString: "after" }), - ) - expect(settled.status).toBe("completed") - if (settled.status !== "completed") return - expect(settled.content).toEqual([ + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "hello.txt") + return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"]) + expect( + (yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map( + (tool) => tool.name, + ), + ).toEqual(["execute"]) + const settled = yield* executeTool( + registry, + call({ path: "hello.txt", oldString: "before", newString: "after" }), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([ + { + type: "text", + text: "Edited hello.txt (1 replacement)", + }, + ]) + // Compact UI metadata carries the file diffs the TUI renders. + expect(settled.metadata).toMatchObject({ + files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }], + }) + expect(settled.output).toEqual({ + replacements: 1, + files: [ { - type: "text", - text: "Edited hello.txt (1 replacement)", + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), }, - ]) - // Compact UI metadata carries the file diffs the TUI renders. - expect(settled.metadata).toMatchObject({ - files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }], - }) - expect(settled.output).toEqual({ - replacements: 1, - files: [ - { - file: "hello.txt", - status: "modified", - additions: 1, - deletions: 1, - patch: expect.stringContaining("-before\n+after"), - }, - ], - }) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") - expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) - expect(assertions[0]?.metadata).toMatchObject({ - files: [ - { - file: "hello.txt", - status: "modified", - additions: 1, - deletions: 1, - patch: expect.stringContaining("-before\n+after"), - }, - ], - }) - expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))]) - }), - ), + ], + }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") + expect(edit.assertions).toMatchObject([ + { sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }, + ]) + expect(edit.assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], + }) + expect(edit.writes).toEqual([yield* Effect.promise(() => fs.realpath(target))]) + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("returns the diff for final formatted content", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "formatted.txt") - formatFile = (file) => - Effect.promise(async () => { - await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER")) - return true - }) - return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - const settled = yield* executeTool( - registry, - call({ path: "formatted.txt", oldString: "before", newString: "after" }), - ) - expect(settled.status).toBe("completed") - if (settled.status !== "completed") return - expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER") - expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER") - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n") - }), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "formatted.txt") + edit.formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER")) + return true + }) + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + const settled = yield* executeTool( + registry, + call({ path: "formatted.txt", oldString: "before", newString: "after" }), + ) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER") + expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n") + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("accepts an absolute file path inside the active Location", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "absolute.txt") - return Effect.promise(() => fs.writeFile(target, "before")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - executeTool(registry, call({ path: target, oldString: "before", newString: "after" })), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "absolute.txt") + return Effect.promise(() => fs.writeFile(target, "before")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + executeTool(registry, call({ path: target, oldString: "before", newString: "after" })), ), - Effect.andThen((result) => - Effect.gen(function* () { - expect(result.status).toBe("completed") - expect(assertions.map((input) => input.action)).toEqual(["edit"]) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after") - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen((result) => + Effect.gen(function* () { + expect(result.status).toBe("completed") + expect(edit.assertions.map((input) => input.action)).toEqual(["edit"]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after") + }), + ), + ) + }), ) it.live("edits an external symlink target with only its in-location permission", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { - reset() + const edit = makeEditFixture() if (process.platform === "win32") return Effect.void const target = path.join(outside.path, "external.txt") const link = path.join(active.path, "link.txt") @@ -259,15 +250,15 @@ describe("EditTool", () => { await fs.symlink(target, link) }).pipe( Effect.andThen( - withTool(active.path, (registry) => + withTool(active.path, edit, (registry) => executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })), ), ), Effect.andThen((result) => Effect.sync(() => { expect(result.status).toBe("completed") - expect(assertions.map((input) => input.action)).toEqual(["edit"]) - expect(assertions[0]?.resources).toEqual(["link.txt"]) + expect(edit.assertions.map((input) => input.action)).toEqual(["edit"]) + expect(edit.assertions[0]?.resources).toEqual(["link.txt"]) }), ), Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), @@ -285,20 +276,20 @@ describe("EditTool", () => { Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { - reset() + const edit = makeEditFixture() const target = path.join(outside.path, "external.txt") return Effect.promise(() => fs.writeFile(target, "before")).pipe( Effect.andThen( - withTool(active.path, (registry) => + withTool(active.path, edit, (registry) => executeTool(registry, call({ path: target, oldString: "before", newString: "after" })), ), ), Effect.andThen((result) => Effect.gen(function* () { expect(result.status).toBe("completed") - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) + expect(edit.assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after") - expect(writes).toHaveLength(1) + expect(edit.writes).toHaveLength(1) }), ), ) @@ -317,33 +308,33 @@ describe("EditTool", () => { Effect.gen(function* () { const external = path.join(outside.path, "denied.txt") yield* Effect.promise(() => fs.writeFile(external, "before")) - reset() - denyAction = "external_directory" + const edit = makeEditFixture() + edit.denyAction = "external_directory" expect( - yield* withTool(active.path, (registry) => + yield* withTool(active.path, edit, (registry) => executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: external_directory" }, }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) - expect(reads).toBe(0) - expect(writes).toEqual([]) + expect(edit.assertions.map((input) => input.action)).toEqual(["external_directory"]) + expect(edit.reads).toBe(0) + expect(edit.writes).toEqual([]) - reset() - denyAction = "edit" + const deniedEdit = makeEditFixture() + deniedEdit.denyAction = "edit" expect( - yield* withTool(active.path, (registry) => + yield* withTool(active.path, deniedEdit, (registry) => executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: edit" }, }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(reads).toBe(1) - expect(writes).toEqual([]) + expect(deniedEdit.assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) + expect(deniedEdit.reads).toBe(1) + expect(deniedEdit.writes).toEqual([]) expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before") }), ([active, outside]) => @@ -354,342 +345,300 @@ describe("EditTool", () => { ) it.live("denied edit does not disclose whether oldString matches", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - denyAction = "edit" - const target = path.join(tmp.path, "secret.txt") - return Effect.promise(() => fs.writeFile(target, "secret content")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - const matching = yield* executeTool( - registry, - call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }), - ) - const missing = yield* executeTool( - registry, - call({ path: "secret.txt", oldString: "not present", newString: "replacement" }), - ) + withTempDir((tmp) => { + const edit = makeEditFixture() + edit.denyAction = "edit" + const target = path.join(tmp.path, "secret.txt") + return Effect.promise(() => fs.writeFile(target, "secret content")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + const matching = yield* executeTool( + registry, + call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }), + ) + const missing = yield* executeTool( + registry, + call({ path: "secret.txt", oldString: "not present", newString: "replacement" }), + ) - expect(matching).toEqual({ - status: "error", - error: { type: "permission.rejected", message: "Permission denied: edit" }, - }) - expect(missing).toEqual(matching) - expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"]) - expect(reads).toBe(2) - expect(writes).toEqual([]) - }), - ), + expect(matching).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Permission denied: edit" }, + }) + expect(missing).toEqual(matching) + expect(edit.assertions.map((input) => input.action)).toEqual(["edit", "edit"]) + expect(edit.reads).toBe(2) + expect(edit.writes).toEqual([]) + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("rejects no-op, empty, missing, and ambiguous exact replacements", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "matches.txt") - return Effect.promise(() => fs.writeFile(target, "same same")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect( - yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })), - ).toEqual({ - status: "error", - error: { - type: "tool.execution", - message: "No changes to apply: oldString and newString are identical.", - }, - }) - expect( - yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })), - ).toEqual({ - status: "error", - error: { - type: "tool.execution", - message: "oldString must not be empty. Use write to create or overwrite a file.", - }, - }) - expect( - yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })), - ).toEqual({ - status: "error", - error: { - type: "tool.execution", - message: - "Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation.", - }, - }) - expect( - yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })), - ).toEqual({ - status: "error", - error: { - type: "tool.execution", - message: - "Found 2 matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.", - }, - }) - expect(writes).toEqual([]) - }), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "matches.txt") + return Effect.promise(() => fs.writeFile(target, "same same")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })), + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: "No changes to apply: oldString and newString are identical.", + }, + }) + expect( + yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })), + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: "oldString must not be empty. Use write to create or overwrite a file.", + }, + }) + expect( + yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })), + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: + "Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation.", + }, + }) + expect( + yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })), + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: + "Found 2 matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.", + }, + }) + expect(edit.writes).toEqual([]) + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("returns specific missing file and directory errors", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const directory = path.join(tmp.path, "src") - return Effect.promise(() => fs.mkdir(directory)).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect( - yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })), - ).toEqual({ - status: "error", - error: { type: "tool.execution", message: "File not found: missing.ts" }, - }) - expect( - yield* executeTool(registry, call({ path: "src", oldString: "before", newString: "after" })), - ).toEqual({ - status: "error", - error: { type: "tool.execution", message: "Path is a directory, not a file: src" }, - }) - expect(writes).toEqual([]) - }), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const directory = path.join(tmp.path, "src") + return Effect.promise(() => fs.mkdir(directory)).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })), + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "File not found: missing.ts" }, + }) + expect( + yield* executeTool(registry, call({ path: "src", oldString: "before", newString: "after" })), + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "Path is a directory, not a file: src" }, + }) + expect(edit.writes).toEqual([]) + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("replaces every exact occurrence when replaceAll is true", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "all.txt") - return Effect.promise(() => fs.writeFile(target, "same same same")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "all.txt") + return Effect.promise(() => fs.writeFile(target, "same same same")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), ), - Effect.andThen((settled) => - Effect.gen(function* () { - expect(settled.status).toBe("completed") - if (settled.status !== "completed") return - expect(settled.output).toMatchObject({ replacements: 3 }) - expect(settled.content).toEqual([{ type: "text", text: "Edited all.txt (3 replacements)" }]) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after") - expect(writes).toHaveLength(1) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen((settled) => + Effect.gen(function* () { + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ replacements: 3 }) + expect(settled.content).toEqual([{ type: "text", text: "Edited all.txt (3 replacements)" }]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after") + expect(edit.writes).toHaveLength(1) + }), + ), + ) + }), ) it.live("normalizes Unicode typography only after exact matching fails", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "unicode.txt") - return Effect.promise(() => - fs.writeFile(target, "exact - match\ncurly “quotes”\nminus − one\nspace\u00A0here\nexact − match\n"), - ).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - const normalized = yield* executeTool( - registry, - call({ - path: "unicode.txt", - oldString: 'curly "quotes"\nminus - one\nspace here', - newString: "normalized", - }), - ) - expect(normalized.status).toBe("completed") + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "unicode.txt") + return Effect.promise(() => + fs.writeFile(target, "exact - match\ncurly “quotes”\nminus − one\nspace\u00A0here\nexact − match\n"), + ).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + const normalized = yield* executeTool( + registry, + call({ + path: "unicode.txt", + oldString: 'curly "quotes"\nminus - one\nspace here', + newString: "normalized", + }), + ) + expect(normalized.status).toBe("completed") - const exact = yield* executeTool( - registry, - call({ path: "unicode.txt", oldString: "exact - match", newString: "selected" }), - ) - expect(exact.status).toBe("completed") - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( - "selected\nnormalized\nexact − match\n", - ) - }), - ), + const exact = yield* executeTool( + registry, + call({ path: "unicode.txt", oldString: "exact - match", newString: "selected" }), + ) + expect(exact.status).toBe("completed") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( + "selected\nnormalized\nexact − match\n", + ) + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("ignores trailing whitespace while preserving untouched lines", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "whitespace.txt") - return Effect.promise(() => fs.writeFile(target, "before \nmatch \nnext\t\nafter \n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - executeTool(registry, call({ path: "whitespace.txt", oldString: "match\nnext", newString: "changed" })), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "whitespace.txt") + return Effect.promise(() => fs.writeFile(target, "before \nmatch \nnext\t\nafter \n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + executeTool(registry, call({ path: "whitespace.txt", oldString: "match\nnext", newString: "changed" })), ), - Effect.tap((result) => Effect.sync(() => expect(result.status).toBe("completed"))), - Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), - Effect.tap((content) => Effect.sync(() => expect(content).toBe("before \nchanged\nafter \n"))), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.tap((result) => Effect.sync(() => expect(result.status).toBe("completed"))), + Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), + Effect.tap((content) => Effect.sync(() => expect(content).toBe("before \nchanged\nafter \n"))), + ) + }), ) it.live("uses non-overlapping trailing-whitespace matches and preserves CRLF", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const overlap = path.join(tmp.path, "overlap.txt") - const windows = path.join(tmp.path, "windows.txt") - return Effect.promise(() => - Promise.all([fs.writeFile(overlap, "a \na \na \n"), fs.writeFile(windows, "a \r\nb\t\r\n")]), - ).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - const replaced = yield* executeTool( - registry, - call({ path: "overlap.txt", oldString: "a\na", newString: "x", replaceAll: true }), - ) - expect(replaced).toMatchObject({ status: "completed", output: { replacements: 1 } }) - yield* executeTool(registry, call({ path: "windows.txt", oldString: "a\nb", newString: "x" })) - }), - ), - ), - Effect.andThen( - Effect.promise(() => Promise.all([fs.readFile(overlap, "utf8"), fs.readFile(windows, "utf8")])), - ), - Effect.tap(([overlapContent, windowsContent]) => - Effect.sync(() => { - expect(overlapContent).toBe("x\na \n") - expect(windowsContent).toBe("x\r\n") + withTempDir((tmp) => { + const edit = makeEditFixture() + const overlap = path.join(tmp.path, "overlap.txt") + const windows = path.join(tmp.path, "windows.txt") + return Effect.promise(() => + Promise.all([fs.writeFile(overlap, "a \na \na \n"), fs.writeFile(windows, "a \r\nb\t\r\n")]), + ).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.gen(function* () { + const replaced = yield* executeTool( + registry, + call({ path: "overlap.txt", oldString: "a\na", newString: "x", replaceAll: true }), + ) + expect(replaced).toMatchObject({ status: "completed", output: { replacements: 1 } }) + yield* executeTool(registry, call({ path: "windows.txt", oldString: "a\nb", newString: "x" })) }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen(Effect.promise(() => Promise.all([fs.readFile(overlap, "utf8"), fs.readFile(windows, "utf8")]))), + Effect.tap(([overlapContent, windowsContent]) => + Effect.sync(() => { + expect(overlapContent).toBe("x\na \n") + expect(windowsContent).toBe("x\r\n") + }), + ), + ) + }), ) it.live("preserves BOM and CRLF line endings", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "windows.txt") - formatFile = (file) => - Effect.promise(async () => { - await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) - return true - }) - return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "windows.txt") + edit.formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, "")) + return true + }) + return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })), ), - Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))), - Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))), + Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))), + ) + }), ) it.live("serializes concurrent edit transactions", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "concurrent.txt") - afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void) - return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.all( - [ - executeTool( - registry, - call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"), - ), - executeTool( - registry, - call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"), - ), - ], - { concurrency: "unbounded" }, - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "concurrent.txt") + edit.afterRead = () => (edit.reads === 1 ? Effect.sleep("50 millis") : Effect.void) + return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + Effect.all( + [ + executeTool( + registry, + call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"), + ), + executeTool( + registry, + call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"), + ), + ], + { concurrency: "unbounded" }, ), ), - Effect.andThen((results) => - Effect.gen(function* () { - expect(results.map((result) => result.status)).toEqual(["completed", "completed"]) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n") - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen((results) => + Effect.gen(function* () { + expect(results.map((result) => result.status)).toEqual(["completed", "completed"]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n") + }), + ), + ) + }), ) it.live("applies the edit when content changes after matching", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "concurrent.txt") - afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void) - return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })), - ), + withTempDir((tmp) => { + const edit = makeEditFixture() + const target = path.join(tmp.path, "concurrent.txt") + edit.afterRead = () => (edit.reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void) + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool(tmp.path, edit, (registry) => + executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })), ), - Effect.andThen((result) => - Effect.gen(function* () { - expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } }) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") - expect(writes).toEqual([target]) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen((result) => + Effect.gen(function* () { + expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") + expect(edit.writes).toEqual([target]) + }), + ), + ) + }), ) }) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index c4f8e672ac9..1f8d13b511a 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -102,7 +102,7 @@ const withTool = ( AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [ [ Environment.node, - transformEnvironmentFiles(activeLocation, (files) => ({ + transformEnvironmentFiles((files) => ({ read: (target, range) => Effect.sync(() => { if (!editApproved) readsBeforeEditApproval++ diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 563e02d2e1c..52fc4a92ad2 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -16,7 +16,7 @@ import { Tool } from "@opencode-ai/core/tool" import { WriteTool } from "@opencode-ai/core/tool/plugin/write" import { transformEnvironmentFiles } from "./fixture/environment" import { location } from "./fixture/location" -import { tmpdir } from "./fixture/tmpdir" +import { tmpdir, withTempDir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" import { permissionLayer } from "./lib/permission" @@ -29,63 +29,68 @@ const writeToolNode = makeLocationNode({ }) const sessionID = Session.ID.make("ses_write_tool_test") -const assertions: Permission.AssertInput[] = [] -const writes: string[] = [] -let formatFile = (_target: string): Effect.Effect => Effect.succeed(false) -let denyAction: string | undefined +const makeWriteFixture = () => { + const fixture: { + assertions: Permission.AssertInput[] + writes: string[] + denyAction?: string + formatFile: (target: string) => Effect.Effect + } = { + assertions: [], + writes: [], + formatFile: () => Effect.succeed(false), + } -const permission = permissionLayer({ - assert: (input) => - Effect.sync(() => assertions.push(input)).pipe( - Effect.andThen( - input.action === denyAction - ? Effect.fail( - new Permission.BlockedError({ - rules: [], - permission: input.action, - resources: input.resources, - }), - ) - : Effect.void, + const permission = permissionLayer({ + assert: (input) => + Effect.sync(() => fixture.assertions.push(input)).pipe( + Effect.andThen( + input.action === fixture.denyAction + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), - ), -}) + }) -const formatter = Layer.mock(Formatter.Service, { - file: (target) => formatFile(target), -}) + const formatter = Layer.mock(Formatter.Service, { + file: (target) => fixture.formatFile(target), + }) -const reset = () => { - assertions.length = 0 - writes.length = 0 - formatFile = () => Effect.succeed(false) - denyAction = undefined + return Object.assign(fixture, { permission, formatter }) } -const withTool = (directory: string, body: (registry: Tool.Interface) => Effect.Effect) => { +const withTool = ( + directory: string, + fixture: ReturnType, + body: (registry: Tool.Interface) => Effect.Effect, +) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) return Effect.gen(function* () { - return yield* body(yield* Tool.Service) + const registry = yield* Tool.Service + return yield* body(registry) }).pipe( Effect.provide( - AppNodeBuilder.build( - LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), + AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [ [ - [ - Environment.node, - transformEnvironmentFiles(activeLocation, (files) => ({ - write: (target, content) => - Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))), - })), - ], - [Location.node, activeLocation], - [Formatter.node, formatter], - [Permission.node, permission], + Environment.node, + transformEnvironmentFiles((files) => ({ + write: (target, content) => + Effect.sync(() => fixture.writes.push(target)).pipe(Effect.andThen(files.write(target, content))), + })), ], - ), + [Location.node, activeLocation], + [Formatter.node, fixture.formatter], + [Permission.node, fixture.permission], + ]), ), ) } @@ -100,175 +105,158 @@ const it = testEffect(Layer.empty) describe("WriteTool", () => { it.live("registers and creates a relative file through FileMutation once", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - return withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"]) - const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" })) - expect(settled).toEqual({ - status: "completed", - output: { - operation: "write", - target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), - resource: "src/new.txt", - existed: false, + withTempDir((tmp) => { + const fixture = makeWriteFixture() + return withTool(tmp.path, fixture, (registry) => + Effect.gen(function* () { + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"]) + const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" })) + expect(settled).toEqual({ + status: "completed", + output: { + operation: "write", + target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), + resource: "src/new.txt", + existed: false, + }, + content: [{ type: "text", text: "Created file successfully: src/new.txt" }], + }) + expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( + "created", + ) + expect(fixture.assertions).toMatchObject([ + { sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }, + ]) + expect(fixture.assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "src/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), }, - content: [{ type: "text", text: "Created file successfully: src/new.txt" }], - }) - expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( - "created", - ) - expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) - expect(assertions[0]?.metadata).toMatchObject({ - files: [ - { - file: "src/new.txt", - status: "added", - additions: 1, - deletions: 0, - patch: expect.stringContaining("+created"), - }, - ], - }) - expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")]) - }), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ], + }) + expect(fixture.writes).toEqual([ + path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), + ]) + }), + ) + }), ) it.live("formats the committed file", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "formatted.txt") - formatFile = (file) => - Effect.promise(async () => { - await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase()) - return true + withTempDir((tmp) => { + const fixture = makeWriteFixture() + const target = path.join(tmp.path, "formatted.txt") + fixture.formatFile = (file) => + Effect.promise(async () => { + await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase()) + return true + }) + return withTool(tmp.path, fixture, (registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({ + status: "completed", }) - return withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({ - status: "completed", - }) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME") - }), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME") + }), + ) + }), ) it.live("overwrites a relative existing file and reports that it wrote the file", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))), + withTempDir((tmp) => { + const fixture = makeWriteFixture() + return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( + Effect.andThen( + withTool(tmp.path, fixture, (registry) => + executeTool(registry, call({ path: "existing.txt", content: "after" })), ), - Effect.andThen((settled) => - Effect.gen(function* () { - expect(settled.status).toBe("completed") - if (settled.status !== "completed") return - expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }]) - expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true }) - expect(assertions[0]?.metadata).toMatchObject({ - files: [ - { - file: "existing.txt", - status: "modified", - additions: 1, - deletions: 1, - patch: expect.stringMatching(/-before[\s\S]*\+after/), - }, - ], - }) - expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe( - "after", - ) - expect(writes).toHaveLength(1) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + Effect.andThen((settled) => + Effect.gen(function* () { + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }]) + expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true }) + expect(fixture.assertions[0]?.metadata).toMatchObject({ + files: [ + { + file: "existing.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringMatching(/-before[\s\S]*\+after/), + }, + ], + }) + expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe("after") + expect(fixture.writes).toHaveLength(1) + }), + ), + ) + }), ) it.live("preserves exactly one BOM when overwriting existing files", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const preserved = path.join(tmp.path, "preserved.txt") - const deduplicated = path.join(tmp.path, "deduplicated.txt") - formatFile = (target) => - Effect.promise(async () => { - await fs.writeFile( - target, - `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`, - ) - return true - }) - return Effect.promise(() => - Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), - ).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) - yield* executeTool( - registry, - call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"), - ) + withTempDir((tmp) => { + const fixture = makeWriteFixture() + const preserved = path.join(tmp.path, "preserved.txt") + const deduplicated = path.join(tmp.path, "deduplicated.txt") + fixture.formatFile = (target) => + Effect.promise(async () => { + await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`) + return true + }) + return Effect.promise(() => + Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]), + ).pipe( + Effect.andThen( + withTool(tmp.path, fixture, (registry) => + Effect.gen(function* () { + yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) + yield* executeTool( + registry, + call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"), + ) - expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter") - expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter") - }), - ), + expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter") + expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter") + }), ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ), + ) + }), ) it.live("accepts an absolute file path inside the active Location", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "absolute.txt") - return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe( - Effect.andThen((result) => - Effect.gen(function* () { - expect(result).toMatchObject({ - status: "completed", - content: [{ type: "text", text: "Created file successfully: absolute.txt" }], - }) - expect(assertions.map((input) => input.action)).toEqual(["edit"]) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside") - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + withTempDir((tmp) => { + const fixture = makeWriteFixture() + const target = path.join(tmp.path, "absolute.txt") + return withTool(tmp.path, fixture, (registry) => + executeTool(registry, call({ path: target, content: "inside" })), + ).pipe( + Effect.andThen((result) => + Effect.gen(function* () { + expect(result).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "Created file successfully: absolute.txt" }], + }) + expect(fixture.assertions.map((input) => input.action)).toEqual(["edit"]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside") + }), + ), + ) + }), ) it.live("writes an external symlink target with only its in-location permission", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { - reset() + const fixture = makeWriteFixture() if (process.platform === "win32") return Effect.void const target = path.join(outside.path, "external.txt") const link = path.join(active.path, "link.txt") @@ -277,13 +265,15 @@ describe("WriteTool", () => { await fs.symlink(target, link) }).pipe( Effect.andThen( - withTool(active.path, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" }))), + withTool(active.path, fixture, (registry) => + executeTool(registry, call({ path: "link.txt", content: "after" })), + ), ), Effect.andThen((result) => Effect.sync(() => { expect(result.status).toBe("completed") - expect(assertions.map((input) => input.action)).toEqual(["edit"]) - expect(assertions[0]?.resources).toEqual(["link.txt"]) + expect(fixture.assertions.map((input) => input.action)).toEqual(["edit"]) + expect(fixture.assertions[0]?.resources).toEqual(["link.txt"]) }), ), Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))), @@ -301,19 +291,22 @@ describe("WriteTool", () => { Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { - reset() + const fixture = makeWriteFixture() const target = path.join(outside.path, "external.txt") - return withTool(active.path, (registry) => + return withTool(active.path, fixture, (registry) => executeTool(registry, call({ path: target, content: "external" })), ).pipe( Effect.andThen((settled) => Effect.gen(function* () { const absoluteTarget = target - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(assertions[0]).toMatchObject({ + expect(fixture.assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) + expect(fixture.assertions[0]).toMatchObject({ resources: [path.join(outside.path, "*").replaceAll("\\", "/")], }) - expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] }) + expect(fixture.assertions[1]).toMatchObject({ + resources: [absoluteTarget.replaceAll("\\", "/")], + save: ["*"], + }) expect(settled).toMatchObject({ status: "completed", output: { @@ -323,7 +316,7 @@ describe("WriteTool", () => { }, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external") - expect(writes).toEqual([absoluteTarget]) + expect(fixture.writes).toEqual([absoluteTarget]) }), ), ) @@ -339,7 +332,7 @@ describe("WriteTool", () => { Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { - reset() + const fixture = makeWriteFixture() const repo = path.join(outside.path, "repo") const nested = path.join(repo, "packages", "app") const target = path.join(nested, "external.txt") @@ -347,11 +340,13 @@ describe("WriteTool", () => { Promise.all([fs.mkdir(path.join(repo, ".git"), { recursive: true }), fs.mkdir(nested, { recursive: true })]), ).pipe( Effect.andThen( - withTool(active.path, (registry) => executeTool(registry, call({ path: target, content: "external" }))), + withTool(active.path, fixture, (registry) => + executeTool(registry, call({ path: target, content: "external" })), + ), ), Effect.andThen( Effect.gen(function* () { - expect(assertions[0]).toMatchObject({ + expect(fixture.assertions[0]).toMatchObject({ action: "external_directory", resources: [path.join(nested, "*").replaceAll("\\", "/")], save: [path.join(repo, "*").replaceAll("\\", "/")], @@ -373,31 +368,31 @@ describe("WriteTool", () => { ([active, outside]) => Effect.gen(function* () { const external = path.join(outside.path, "denied.txt") - reset() - denyAction = "external_directory" + const fixture = makeWriteFixture() + fixture.denyAction = "external_directory" expect( - yield* withTool(active.path, (registry) => + yield* withTool(active.path, fixture, (registry) => executeTool(registry, call({ path: external, content: "blocked" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: external_directory" }, }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) - expect(writes).toEqual([]) + expect(fixture.assertions.map((input) => input.action)).toEqual(["external_directory"]) + expect(fixture.writes).toEqual([]) - reset() - denyAction = "edit" + const deniedEdit = makeWriteFixture() + deniedEdit.denyAction = "edit" expect( - yield* withTool(active.path, (registry) => + yield* withTool(active.path, deniedEdit, (registry) => executeTool(registry, call({ path: "denied.txt", content: "blocked" })), ), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: edit" }, }) - expect(assertions.map((input) => input.action)).toEqual(["edit"]) - expect(writes).toEqual([]) + expect(deniedEdit.assertions.map((input) => input.action)).toEqual(["edit"]) + expect(deniedEdit.writes).toEqual([]) }), ([active, outside]) => Effect.promise(() =>