2026-06-04 03:02:17 +00:00
import fs from "fs/promises"
import path from "path"
2026-07-22 15:50:40 +00:00
import { describe , expect } from "bun:test"
2026-06-04 03:02:17 +00:00
import { Effect , Layer } from "effect"
2026-07-01 21:12:00 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
2026-07-21 14:34:29 +00:00
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
2026-06-04 03:02:17 +00:00
import { FileMutation } from "@opencode-ai/core/file-mutation"
2026-07-21 14:34:29 +00:00
import { FSUtil } from "@opencode-ai/util/fs-util"
2026-06-04 03:02:17 +00:00
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
2026-07-27 00:08:55 +00:00
import { Permission } from "@opencode-ai/core/permission"
2026-06-04 03:02:17 +00:00
import { AbsolutePath } from "@opencode-ai/core/schema"
2026-07-27 00:08:55 +00:00
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
2026-06-04 03:02:17 +00:00
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
2026-07-21 14:34:29 +00:00
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
2026-06-04 03:02:17 +00:00
import { testEffect } from "./lib/effect"
2026-07-23 21:13:31 +00:00
import { toolIdentity , executeTool , registerToolPlugin , toolDefinitions } from "./lib/tool"
2026-07-03 13:03:53 +00:00
const editToolNode = makeLocationNode ( {
name : "test/edit-tool-plugin" ,
layer : Layer.effectDiscard ( registerToolPlugin ( EditTool . Plugin ) ) ,
2026-07-27 00:08:55 +00:00
deps : [ Tool . node , LocationMutation . node , FileMutation . node , FSUtil . node , Permission . node ] ,
2026-07-03 13:03:53 +00:00
} )
2026-06-04 03:02:17 +00:00
2026-07-27 00:08:55 +00:00
const sessionID = Session . ID . make ( "ses_edit_tool_test" )
const assertions : Permission.AssertInput [ ] = [ ]
2026-06-04 03:02:17 +00:00
const writes : string [ ] = [ ]
let reads = 0
let denyAction : string | undefined
let afterRead = ( _target : string , _content : Uint8Array ) : Effect . Effect < void > = > Effect . void
const permission = Layer . succeed (
2026-07-27 00:08:55 +00:00
Permission . Service ,
Permission . Service . of ( {
2026-06-04 03:02:17 +00:00
assert : ( input ) = >
Effect . sync ( ( ) = > assertions . push ( input ) ) . pipe (
Effect . andThen (
2026-07-06 22:14:06 +00:00
input . action === denyAction
? Effect . fail (
2026-07-27 00:08:55 +00:00
new Permission . BlockedError ( {
2026-07-06 22:14:06 +00:00
rules : [ ] ,
permission : input.action ,
resources : input.resources ,
} ) ,
)
: Effect . void ,
2026-06-04 03:02:17 +00:00
) ,
) ,
ask : ( ) = > Effect . die ( "unused" ) ,
reply : ( ) = > Effect . die ( "unused" ) ,
get : ( ) = > Effect . die ( "unused" ) ,
forSession : ( ) = > Effect . die ( "unused" ) ,
list : ( ) = > Effect . die ( "unused" ) ,
} ) ,
)
const reset = ( ) = > {
assertions . length = 0
writes . length = 0
reads = 0
denyAction = undefined
afterRead = ( ) = > Effect . void
}
const filesystem = Layer . effect (
FSUtil . Service ,
Effect . gen ( function * ( ) {
const fs = yield * FSUtil . Service
return FSUtil . Service . of ( {
. . . fs ,
readFile : ( target ) = >
2026-06-04 03:03:39 +00:00
fs
. readFile ( target )
. pipe (
Effect . tap ( ( content ) = >
Effect . sync ( ( ) = > reads ++ ) . pipe ( Effect . andThen ( Effect . suspend ( ( ) = > afterRead ( target , content ) ) ) ) ,
) ,
2026-06-04 03:02:17 +00:00
) ,
writeWithDirs : ( target , content , mode ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeWithDirs ( target , content , mode ) ) ) ,
2026-06-06 03:08:23 +00:00
writeFile : ( target , content , options ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeFile ( target , content , options ) ) ) ,
writeFileString : ( target , content , options ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeFileString ( target , content , options ) ) ) ,
2026-06-04 03:02:17 +00:00
} )
} ) ,
2026-07-01 21:12:00 +00:00
) . pipe ( Layer . provide ( LayerNode . compile ( FSUtil . node ) ) )
2026-06-04 03:02:17 +00:00
2026-07-27 00:08:55 +00:00
const withTool = < A , E , R > ( directory : string , body : ( registry : Tool.Interface ) = > Effect . Effect < A , E , R > ) = > {
2026-06-04 03:02:17 +00:00
const activeLocation = Layer . succeed (
Location . Service ,
Location . Service . of ( location ( { directory : AbsolutePath.make ( directory ) } ) ) ,
)
return Effect . gen ( function * ( ) {
2026-07-27 00:08:55 +00:00
return yield * body ( yield * Tool . Service )
2026-07-01 21:12:00 +00:00
} ) . pipe (
Effect . provide (
AppNodeBuilder . build (
LayerNode . group ( [
2026-07-27 00:08:55 +00:00
Tool . node ,
Tool . node ,
2026-07-01 21:12:00 +00:00
LocationMutation . node ,
FileMutation . node ,
2026-07-03 13:03:53 +00:00
editToolNode ,
2026-07-01 21:12:00 +00:00
] ) ,
[
[ FSUtil . node , filesystem ] ,
[ Location . node , activeLocation ] ,
2026-07-27 00:08:55 +00:00
[ Permission . node , permission ] ,
2026-07-01 21:12:00 +00:00
] ,
) ,
) ,
)
2026-06-04 03:02:17 +00:00
}
2026-06-07 01:55:34 +00:00
const call = ( input : typeof EditTool . Input . Type , id = "call-edit" ) = > ( {
2026-06-04 03:02:17 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-04 03:02:17 +00:00
call : { type : "tool-call" as const , id , name : "edit" , input } ,
} )
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 * ( ) {
2026-07-25 18:01:05 +00:00
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" ] )
2026-07-23 21:13:31 +00:00
const settled = yield * executeTool (
2026-06-07 00:49:12 +00:00
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "hello.txt" , oldString : "before" , newString : "after" } ) ,
)
2026-07-23 21:13:31 +00:00
expect ( settled . status ) . toBe ( "completed" )
if ( settled . status !== "completed" ) return
expect ( settled . content ) . toEqual ( [
{
type : "text" ,
2026-07-28 00:11:04 +00:00
text : "Edited hello.txt (1 replacement)" ,
2026-07-23 21:13:31 +00:00
} ,
] )
// Compact UI metadata carries the file diffs the TUI renders.
expect ( settled . metadata ) . toMatchObject ( {
files : [ { file : "hello.txt" , status : "modified" , additions : 1 , deletions : 1 } ] ,
2026-06-04 03:02:17 +00:00
} )
2026-07-23 21:13:31 +00:00
expect ( settled . output ) . toEqual ( {
2026-06-04 03:02:17 +00:00
replacements : 1 ,
2026-06-26 18:20:47 +00:00
files : [
{
file : "hello.txt" ,
status : "modified" ,
additions : 1 ,
deletions : 1 ,
patch : expect.stringContaining ( "-before\n+after" ) ,
} ,
] ,
2026-06-04 03:02:17 +00:00
} )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after\nrest\n" )
2026-06-07 00:49:12 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "edit" , resources : [ "hello.txt" ] , save : [ "*" ] } ] )
2026-06-04 03:02:17 +00:00
expect ( writes ) . toEqual ( [ yield * Effect . promise ( ( ) = > fs . realpath ( target ) ) ] )
} ) ,
) ,
) ,
)
} ,
( 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 ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : target , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
2026-07-23 21:13:31 +00:00
expect ( result . status ) . toBe ( "completed" )
2026-06-04 03:02:17 +00:00
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after" )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-07-21 20:13:18 +00:00
it . live ( "edits an external symlink target with only its in-location permission" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = > {
reset ( )
if ( process . platform === "win32" ) return Effect . void
const target = path . join ( outside . path , "external.txt" )
const link = path . join ( active . path , "link.txt" )
return Effect . promise ( async ( ) = > {
await fs . writeFile ( target , "before" )
await fs . symlink ( target , link )
} ) . pipe (
Effect . andThen (
withTool ( active . path , ( registry ) = >
executeTool ( registry , call ( { path : "link.txt" , oldString : "before" , newString : "after" } ) ) ,
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . sync ( ( ) = > {
2026-07-23 21:13:31 +00:00
expect ( result . status ) . toBe ( "completed" )
2026-07-21 20:13:18 +00:00
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" ] )
expect ( assertions [ 0 ] ? . resources ) . toEqual ( [ "link.txt" ] )
} ) ,
) ,
Effect . andThen ( Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) ,
Effect . tap ( ( content ) = > Effect . sync ( ( ) = > expect ( content ) . toBe ( "after" ) ) ) ,
)
} ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
2026-06-04 03:02:17 +00:00
it . live ( "approves an explicit external absolute path before edit" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = > {
reset ( )
const target = path . join ( outside . path , "external.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "before" ) ) . pipe (
Effect . andThen (
withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : target , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
2026-07-23 21:13:31 +00:00
expect ( result . status ) . toBe ( "completed" )
2026-06-04 03:02:17 +00:00
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" , "edit" ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after" )
expect ( writes ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "does not write when external_directory or edit approval is denied" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = >
Effect . gen ( function * ( ) {
const external = path . join ( outside . path , "denied.txt" )
yield * Effect . promise ( ( ) = > fs . writeFile ( external , "before" ) )
reset ( )
denyAction = "external_directory"
expect (
yield * withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : external , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : { type : "permission.rejected" , message : "Permission denied: external_directory" } ,
2026-06-04 03:02:17 +00:00
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" ] )
expect ( reads ) . toBe ( 0 )
expect ( writes ) . toEqual ( [ ] )
reset ( )
denyAction = "edit"
expect (
yield * withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : external , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : { type : "permission.rejected" , message : "Permission denied: edit" } ,
2026-06-04 03:02:17 +00:00
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" , "edit" ] )
expect ( reads ) . toBe ( 0 )
expect ( writes ) . toEqual ( [ ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( external , "utf8" ) ) ) . toBe ( "before" )
} ) ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "denied edit reads no target content and 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 * ( ) {
2026-06-07 00:49:12 +00:00
const matching = yield * executeTool (
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "secret.txt" , oldString : "secret content" , newString : "replacement" } ) ,
)
2026-06-07 00:49:12 +00:00
const missing = yield * executeTool (
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "secret.txt" , oldString : "not present" , newString : "replacement" } ) ,
)
2026-07-23 21:13:31 +00:00
expect ( matching ) . toEqual ( {
status : "error" ,
error : { type : "permission.rejected" , message : "Permission denied: edit" } ,
} )
2026-06-04 03:02:17 +00:00
expect ( missing ) . toEqual ( matching )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" , "edit" ] )
expect ( reads ) . toBe ( 0 )
expect ( 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 (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "same" , newString : "same" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : {
type : "tool.execution" ,
message : "No changes to apply: oldString and newString are identical." ,
} ,
2026-06-04 03:02:17 +00:00
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : {
type : "tool.execution" ,
message : "oldString must not be empty. Use write to create or overwrite a file." ,
} ,
2026-06-04 03:02:17 +00:00
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "missing" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : {
type : "tool.execution" ,
message :
2026-07-28 00:11:04 +00:00
"Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation." ,
2026-07-23 21:13:31 +00:00
} ,
2026-06-04 03:02:17 +00:00
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "same" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
error : {
type : "tool.execution" ,
message :
2026-07-28 00:11:04 +00:00
"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." ,
2026-07-23 21:13:31 +00:00
} ,
2026-06-04 03:02:17 +00:00
} )
expect ( writes ) . toEqual ( [ ] )
} ) ,
) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-07-28 00:11:04 +00:00
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 ( [ ] )
} ) ,
) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-06-04 03:02:17 +00:00
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 ) = >
2026-07-23 21:13:31 +00:00
executeTool ( registry , call ( { path : "all.txt" , oldString : "same" , newString : "after" , replaceAll : true } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( settled ) = >
Effect . gen ( function * ( ) {
2026-07-23 21:13:31 +00:00
expect ( settled . status ) . toBe ( "completed" )
if ( settled . status !== "completed" ) return
expect ( settled . output ) . toMatchObject ( { replacements : 3 } )
2026-07-28 00:11:04 +00:00
expect ( settled . content ) . toEqual ( [ { type : "text" , text : "Edited all.txt (3 replacements)" } ] )
2026-06-04 03:02:17 +00:00
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after after after" )
expect ( writes ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-07-28 05:06:43 +00:00
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" )
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" } ) ) ,
) ,
) ,
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 ] ( ) ) ,
) ,
)
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" )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
2026-06-04 03:02:17 +00:00
it . live ( "preserves BOM and CRLF line endings" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "windows.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "\uFEFFbefore\r\nrest\r\n" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : "windows.txt" , oldString : "before\nrest" , newString : "after\nrest" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
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 ] ( ) ) ,
) ,
)
2026-07-27 22:03:00 +00:00
it . live ( "applies the edit when content changes after matching" , ( ) = >
2026-06-04 03:02:17 +00:00
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 ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : "concurrent.txt" , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
2026-07-27 22:03:00 +00:00
expect ( result ) . toMatchObject ( { status : "completed" , output : { replacements : 1 } } )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after\n" )
expect ( writes ) . toEqual ( [ target ] )
2026-06-04 03:02:17 +00:00
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
} )