mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
7a9125b953
When a caller sends multiple entries for the same literal path, merge their edits into a single entry rather than returning 400. Symlink aliases (different paths, same real file) are still rejected.
3607 lines
108 KiB
Go
3607 lines
108 KiB
Go
package agentfiles_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"testing/iotest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/agent/agentchat"
|
|
"github.com/coder/coder/v2/agent/agentfiles"
|
|
"github.com/coder/coder/v2/agent/agentgit"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
type testFs struct {
|
|
afero.Fs
|
|
// intercept can return an error for testing when a call fails.
|
|
intercept func(call, file string) error
|
|
}
|
|
|
|
func newTestFs(base afero.Fs, intercept func(call, file string) error) *testFs {
|
|
return &testFs{
|
|
Fs: base,
|
|
intercept: intercept,
|
|
}
|
|
}
|
|
|
|
func (fs *testFs) Open(name string) (afero.File, error) {
|
|
if err := fs.intercept("open", name); err != nil {
|
|
return nil, err
|
|
}
|
|
return fs.Fs.Open(name)
|
|
}
|
|
|
|
func (fs *testFs) Create(name string) (afero.File, error) {
|
|
if err := fs.intercept("create", name); err != nil {
|
|
return nil, err
|
|
}
|
|
// Unlike os, afero lets you create files where directories already exist and
|
|
// lets you nest them underneath files, somehow.
|
|
stat, err := fs.Fs.Stat(name)
|
|
if err == nil && stat.IsDir() {
|
|
return nil, &os.PathError{
|
|
Op: "open",
|
|
Path: name,
|
|
Err: syscall.EISDIR,
|
|
}
|
|
}
|
|
stat, err = fs.Fs.Stat(filepath.Dir(name))
|
|
if err == nil && !stat.IsDir() {
|
|
return nil, &os.PathError{
|
|
Op: "open",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
return fs.Fs.Create(name)
|
|
}
|
|
|
|
func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
|
|
if err := fs.intercept("mkdirall", name); err != nil {
|
|
return err
|
|
}
|
|
// Unlike os, afero lets you create directories where files already exist and
|
|
// lets you nest them underneath files somehow.
|
|
stat, err := fs.Fs.Stat(filepath.Dir(name))
|
|
if err == nil && !stat.IsDir() {
|
|
return &os.PathError{
|
|
Op: "mkdir",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
stat, err = fs.Fs.Stat(name)
|
|
if err == nil && !stat.IsDir() {
|
|
return &os.PathError{
|
|
Op: "mkdir",
|
|
Path: name,
|
|
Err: syscall.ENOTDIR,
|
|
}
|
|
}
|
|
return fs.Fs.MkdirAll(name, mode)
|
|
}
|
|
|
|
func (fs *testFs) Rename(oldName, newName string) error {
|
|
if err := fs.intercept("rename", newName); err != nil {
|
|
return err
|
|
}
|
|
return fs.Fs.Rename(oldName, newName)
|
|
}
|
|
|
|
func TestReadFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms")
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "a-directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
filePath := filepath.Join(tmpdir, "file")
|
|
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
imagePath := filepath.Join(tmpdir, "file.png")
|
|
err = afero.WriteFile(fs, imagePath, []byte("not really an image"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
limit int64
|
|
offset int64
|
|
bytes []byte
|
|
mimeType string
|
|
errCode int
|
|
error string
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
errCode: http.StatusBadRequest,
|
|
error: "\"path\" is required",
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
path: "./relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "also-relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NegativeLimit",
|
|
path: filePath,
|
|
limit: -10,
|
|
errCode: http.StatusBadRequest,
|
|
error: "value is negative",
|
|
},
|
|
{
|
|
name: "NegativeOffset",
|
|
path: filePath,
|
|
offset: -10,
|
|
errCode: http.StatusBadRequest,
|
|
error: "value is negative",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "does-not-exist"),
|
|
errCode: http.StatusNotFound,
|
|
error: "file does not exist",
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
errCode: http.StatusBadRequest,
|
|
error: "not a file",
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
path: noPermsFilePath,
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
{
|
|
name: "Defaults",
|
|
path: filePath,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit1",
|
|
path: filePath,
|
|
limit: 1,
|
|
bytes: []byte("c"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset1",
|
|
path: filePath,
|
|
offset: 1,
|
|
bytes: []byte("ontent"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit1Offset2",
|
|
path: filePath,
|
|
limit: 1,
|
|
offset: 2,
|
|
bytes: []byte("n"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit7Offset0",
|
|
path: filePath,
|
|
limit: 7,
|
|
offset: 0,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Limit100",
|
|
path: filePath,
|
|
limit: 100,
|
|
bytes: []byte("content"),
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset7",
|
|
path: filePath,
|
|
offset: 7,
|
|
bytes: []byte{},
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "Offset100",
|
|
path: filePath,
|
|
offset: 100,
|
|
bytes: []byte{},
|
|
mimeType: "application/octet-stream",
|
|
},
|
|
{
|
|
name: "MimeTypePng",
|
|
path: imagePath,
|
|
bytes: []byte("not really an image"),
|
|
mimeType: "image/png",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, tt.error)
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
bytes, err := io.ReadAll(w.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.bytes, bytes)
|
|
require.Equal(t, tt.mimeType, w.Header().Get("Content-Type"))
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
|
noPermsDirPath := filepath.Join(tmpdir, "no-perms-dir")
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath || file == noPermsDirPath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
filePath := filepath.Join(tmpdir, "file")
|
|
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
notDirErr := "not a directory"
|
|
if runtime.GOOS == "windows" {
|
|
notDirErr = "cannot find the path"
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
bytes []byte
|
|
errCode int
|
|
error string
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
errCode: http.StatusBadRequest,
|
|
error: "\"path\" is required",
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
path: "./relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "also-relative",
|
|
errCode: http.StatusBadRequest,
|
|
error: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "/nested/does-not-exist"),
|
|
bytes: []byte("now it does exist"),
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
errCode: http.StatusBadRequest,
|
|
error: "is a directory",
|
|
},
|
|
{
|
|
name: "IsNotDir",
|
|
path: filepath.Join(filePath, "file2"),
|
|
errCode: http.StatusBadRequest,
|
|
error: notDirErr,
|
|
},
|
|
{
|
|
name: "NoPermissionsFile",
|
|
path: noPermsFilePath,
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
{
|
|
name: "NoPermissionsDir",
|
|
path: filepath.Join(noPermsDirPath, "within-no-perm-dir"),
|
|
errCode: http.StatusForbidden,
|
|
error: "permission denied",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
reader := bytes.NewReader(tt.bytes)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("/write-file?path=%s", tt.path), reader)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, tt.error)
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
bytes, err := afero.ReadFile(fs, tt.path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.bytes, bytes)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteFile_ReportsIOError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
tmpdir := os.TempDir()
|
|
path := filepath.Join(tmpdir, "write-io-error")
|
|
err := afero.WriteFile(fs, path, []byte("original"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
// A reader that always errors simulates a failed body read
|
|
// (e.g. network interruption). The atomic write should leave
|
|
// the original file intact.
|
|
body := iotest.ErrReader(xerrors.New("simulated I/O error"))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
fmt.Sprintf("/write-file?path=%s", path), body)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
|
got := &codersdk.Error{}
|
|
err = json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, got, "simulated I/O error")
|
|
|
|
// The original file must survive the failed write.
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "original", string(data))
|
|
}
|
|
|
|
func TestWriteFile_PreservesPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file permissions are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
path := filepath.Join(dir, "script.sh")
|
|
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
|
require.NoError(t, err)
|
|
|
|
info, err := osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
// Overwrite the file with new content.
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
fmt.Sprintf("/write-file?path=%s", path),
|
|
bytes.NewReader([]byte("#!/bin/sh\necho world\n")))
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
data, err := afero.ReadFile(osFs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
|
|
|
info, err = osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
|
"write_file should preserve the original file's permissions")
|
|
}
|
|
|
|
func TestEditFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
|
|
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return &os.PathError{
|
|
Op: call,
|
|
Path: file,
|
|
Err: os.ErrPermission,
|
|
}
|
|
} else if file == failRenameFilePath && call == "rename" {
|
|
return xerrors.New("rename failed")
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "directory")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
contents map[string]string
|
|
edits []workspacesdk.FileEdits
|
|
expected map[string]string
|
|
errCode int
|
|
errors []string
|
|
}{
|
|
{
|
|
name: "NoFiles",
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"must specify at least one file"},
|
|
},
|
|
{
|
|
name: "NoPath",
|
|
errCode: http.StatusBadRequest,
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errors: []string{"\"path\" is required"},
|
|
},
|
|
{
|
|
name: "RelativePathDotSlash",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "./relative",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"file path must be absolute"},
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "also-relative",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"file path must be absolute"},
|
|
},
|
|
{
|
|
name: "NoEdits",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "no-edits"),
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"must specify at least one edit"},
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "does-not-exist"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusNotFound,
|
|
errors: []string{"file does not exist"},
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: dirPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"not a file"},
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: noPermsFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusForbidden,
|
|
errors: []string{"permission denied"},
|
|
},
|
|
{
|
|
name: "FailRename",
|
|
contents: map[string]string{failRenameFilePath: "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: failRenameFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusInternalServerError,
|
|
errors: []string{"rename failed"},
|
|
// Original file must survive the failed rename.
|
|
expected: map[string]string{failRenameFilePath: "foo bar"},
|
|
},
|
|
{
|
|
name: "Edit1",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit1"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit1"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"},
|
|
},
|
|
{
|
|
// When the second edit creates ambiguity (two "bar"
|
|
// occurrences), it should fail.
|
|
name: "EditEditAmbiguous",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit-edit"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
{
|
|
Search: "bar",
|
|
Replace: "qux",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"matches 2 occurrences"},
|
|
// File should not be modified on error.
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
|
},
|
|
{
|
|
// With replace_all the cascading edit replaces
|
|
// both occurrences.
|
|
name: "EditEditReplaceAll",
|
|
contents: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "foo bar"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "edit-edit-ra"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "bar",
|
|
},
|
|
{
|
|
Search: "bar",
|
|
Replace: "qux",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "qux qux"},
|
|
},
|
|
{
|
|
name: "Multiline",
|
|
contents: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nbar\nbaz\nqux"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "multiline"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "bar\nbaz",
|
|
Replace: "frob",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "multiline"): "foo\nfrob\nqux"},
|
|
},
|
|
{
|
|
name: "Multifile",
|
|
contents: map[string]string{
|
|
filepath.Join(tmpdir, "file1"): "file 1",
|
|
filepath.Join(tmpdir, "file2"): "file 2",
|
|
filepath.Join(tmpdir, "file3"): "file 3",
|
|
},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "file1"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file2"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file3"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited3",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{
|
|
filepath.Join(tmpdir, "file1"): "edited1 1",
|
|
filepath.Join(tmpdir, "file2"): "edited2 2",
|
|
filepath.Join(tmpdir, "file3"): "edited3 3",
|
|
},
|
|
},
|
|
{
|
|
name: "TrailingWhitespace",
|
|
contents: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "foo \nbar\t\t\nbaz"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "trailing-ws"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo\nbar\nbaz",
|
|
Replace: "replaced",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// The file's trailing whitespace (" " on line 1,
|
|
// "\t\t" on line 2) agrees with both search and replace
|
|
// (both have no trailing whitespace on their single
|
|
// lines), so the splice preserves the file's trailing
|
|
// whitespace. File's trailing whitespace on line 1 is
|
|
// preserved; the replacement collapses to one line, so
|
|
// lines 2 and 3 are consumed and only the first line's
|
|
// trailing whitespace remains.
|
|
expected: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "replaced "},
|
|
},
|
|
{
|
|
name: "TabsVsSpaces",
|
|
contents: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tfoo()\n\t}"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "tabs-vs-spaces"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search uses spaces but file uses tabs.
|
|
Search: " if true {\n foo()\n }",
|
|
Replace: "\tif true {\n\t\tbar()\n\t}",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tbar()\n\t}"},
|
|
},
|
|
{
|
|
name: "DifferentIndentDepth",
|
|
contents: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tnested()"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "indent-depth"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search has wrong indent depth (1 tab instead of 3).
|
|
Search: "\tdeep()\n\tnested()",
|
|
Replace: "\t\t\tdeep()\n\t\t\tchanged()",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tchanged()"},
|
|
},
|
|
{
|
|
name: "ExactMatchPreferred",
|
|
contents: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "hello world"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "exact-preferred"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello world",
|
|
Replace: "goodbye world",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "goodbye world"},
|
|
},
|
|
{
|
|
name: "NoMatchErrors",
|
|
contents: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "no-match"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "this does not exist in the file",
|
|
Replace: "whatever",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"search string not found in file"},
|
|
// File should remain unchanged.
|
|
expected: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
|
},
|
|
{
|
|
name: "AmbiguousExactMatch",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ambig-exact"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "qux",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"matches 3 occurrences"},
|
|
expected: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
|
},
|
|
{
|
|
name: "ReplaceAllExact",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ra-exact"): "foo bar foo baz foo"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ra-exact"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "foo",
|
|
Replace: "qux",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "ra-exact"): "qux bar qux baz qux"},
|
|
},
|
|
{
|
|
// replace_all with fuzzy trailing-whitespace match.
|
|
name: "ReplaceAllFuzzyTrailing",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "hello \nworld\nhello \nagain"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ra-fuzzy-trail"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello\n",
|
|
Replace: "bye\n",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// File trailing whitespace " " on "hello " lines is
|
|
// preserved because search and replace agree on having
|
|
// no trailing whitespace. Replace-all runs the same
|
|
// per-position splice as single-replace.
|
|
expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "bye \nworld\nbye \nagain"},
|
|
},
|
|
{
|
|
// replace_all with fuzzy indent match (pass 3).
|
|
name: "ReplaceAllFuzzyIndent",
|
|
contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\talpha\n\t\tbeta\n\t\talpha\n\t\tgamma"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "ra-fuzzy-indent"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search uses different indentation (spaces instead of tabs).
|
|
Search: " alpha\n",
|
|
Replace: "\t\tREPLACED\n",
|
|
ReplaceAll: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\tREPLACED\n\t\tbeta\n\t\tREPLACED\n\t\tgamma"},
|
|
},
|
|
{
|
|
name: "MixedWhitespaceMultiline",
|
|
contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "mixed-ws"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
// Search uses spaces, file uses tabs.
|
|
Search: " result := compute()\n fmt.Println(result)\n",
|
|
Replace: "\tresult := compute()\n\tlog.Println(result)\n",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
expected: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tlog.Println(result)\n}"},
|
|
},
|
|
{
|
|
name: "MultiError",
|
|
contents: map[string]string{
|
|
filepath.Join(tmpdir, "file8"): "file 8",
|
|
},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: noPermsFilePath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited7",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file8"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited8",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "file9"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "file",
|
|
Replace: "edited9",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// No files should be modified when any edit fails
|
|
// (atomic multi-file semantics).
|
|
expected: map[string]string{
|
|
filepath.Join(tmpdir, "file8"): "file 8",
|
|
},
|
|
// Higher status codes will override lower ones, so in this case the 404
|
|
// takes priority over the 403.
|
|
errCode: http.StatusNotFound,
|
|
errors: []string{
|
|
fmt.Sprintf("%s: permission denied", noPermsFilePath),
|
|
"file9: file does not exist",
|
|
},
|
|
},
|
|
{
|
|
// Valid edits on files A and C, but file B has a
|
|
// search miss. None should be written.
|
|
name: "AtomicMultiFile_OneFailsNoneWritten",
|
|
contents: map[string]string{
|
|
filepath.Join(tmpdir, "atomic-a"): "aaa",
|
|
filepath.Join(tmpdir, "atomic-b"): "bbb",
|
|
filepath.Join(tmpdir, "atomic-c"): "ccc",
|
|
},
|
|
edits: []workspacesdk.FileEdits{
|
|
{
|
|
Path: filepath.Join(tmpdir, "atomic-a"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "aaa", Replace: "AAA"},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "atomic-b"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "NOTFOUND", Replace: "XXX"},
|
|
},
|
|
},
|
|
{
|
|
Path: filepath.Join(tmpdir, "atomic-c"),
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "ccc", Replace: "CCC"},
|
|
},
|
|
},
|
|
},
|
|
errCode: http.StatusBadRequest,
|
|
errors: []string{"search string not found"},
|
|
expected: map[string]string{
|
|
filepath.Join(tmpdir, "atomic-a"): "aaa",
|
|
filepath.Join(tmpdir, "atomic-b"): "bbb",
|
|
filepath.Join(tmpdir, "atomic-c"): "ccc",
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
for path, content := range tt.contents {
|
|
err := afero.WriteFile(fs, path, []byte(content), 0o644)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err := enc.Encode(workspacesdk.FileEditRequest{Files: tt.edits})
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.errCode != 0 {
|
|
got := &codersdk.Error{}
|
|
err := json.NewDecoder(w.Body).Decode(got)
|
|
require.NoError(t, err)
|
|
for _, error := range tt.errors {
|
|
require.ErrorContains(t, got, error)
|
|
}
|
|
require.Equal(t, tt.errCode, w.Code)
|
|
} else {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
for path, expect := range tt.expected {
|
|
b, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expect, string(b))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEditFiles_PreservesPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file permissions are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
path := filepath.Join(dir, "script.sh")
|
|
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
|
require.NoError(t, err)
|
|
|
|
// Sanity-check the initial mode.
|
|
info, err := osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
body := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello",
|
|
Replace: "world",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err = enc.Encode(body)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// Verify content was updated.
|
|
data, err := afero.ReadFile(osFs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
|
|
|
// Verify permissions are preserved after the
|
|
// temp-file-and-rename cycle.
|
|
info, err = osFs.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
|
"edit_files should preserve the original file's permissions")
|
|
}
|
|
|
|
func TestHandleWriteFile_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
chatID := uuid.New()
|
|
ancestorID := uuid.New()
|
|
ancestorJSON, _ := json.Marshal([]string{ancestorID.String()})
|
|
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
req.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, string(ancestorJSON))
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
agentchat.Middleware(r).ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// Verify PathStore was updated for both chat and ancestor.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Equal(t, []string{testPath}, paths)
|
|
|
|
ancestorPaths := pathStore.GetPaths(ancestorID)
|
|
require.Equal(t, []string{testPath}, ancestorPaths)
|
|
}
|
|
|
|
func TestHandleWriteFile_NoChatHeaders_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
agentchat.Middleware(r).ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// PathStore should be globally empty since no chat headers were set.
|
|
require.Equal(t, 0, pathStore.Len())
|
|
}
|
|
|
|
func TestHandleWriteFile_Failure_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
chatID := uuid.New()
|
|
|
|
// Write to a relative path (should fail with 400).
|
|
body := strings.NewReader("hello world")
|
|
req := httptest.NewRequest(http.MethodPost, "/write-file?path=relative/path.txt", body)
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/write-file", api.HandleWriteFile)
|
|
agentchat.Middleware(r).ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rr.Code)
|
|
|
|
// PathStore should NOT be updated on failure.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Empty(t, paths)
|
|
}
|
|
|
|
func TestHandleEditFiles_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
testPath := filepath.Join(os.TempDir(), "test.txt")
|
|
|
|
// Create the file first.
|
|
require.NoError(t, afero.WriteFile(fs, testPath, []byte("hello"), 0o644))
|
|
|
|
chatID := uuid.New()
|
|
editReq := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: testPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "world"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body, _ := json.Marshal(editReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/edit-files", api.HandleEditFiles)
|
|
agentchat.Middleware(r).ServeHTTP(rr, req)
|
|
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Equal(t, []string{testPath}, paths)
|
|
}
|
|
|
|
func TestHandleEditFiles_Failure_NoPathStoreUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pathStore := agentgit.NewPathStore()
|
|
logger := slogtest.Make(t, nil)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, pathStore)
|
|
|
|
chatID := uuid.New()
|
|
|
|
// Edit a non-existent file (should fail with 404).
|
|
editReq := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: "/nonexistent/file.txt",
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "world"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body, _ := json.Marshal(editReq)
|
|
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
|
|
|
|
rr := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Post("/edit-files", api.HandleEditFiles)
|
|
agentchat.Middleware(r).ServeHTTP(rr, req)
|
|
|
|
require.NotEqual(t, http.StatusOK, rr.Code)
|
|
|
|
// PathStore should NOT be updated on failure.
|
|
paths := pathStore.GetPaths(chatID)
|
|
require.Empty(t, paths)
|
|
}
|
|
|
|
func TestReadFileLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
noPermsFilePath := filepath.Join(tmpdir, "no-perms-lines")
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
|
|
if file == noPermsFilePath {
|
|
return os.ErrPermission
|
|
}
|
|
return nil
|
|
})
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
|
|
dirPath := filepath.Join(tmpdir, "a-directory-lines")
|
|
err := fs.MkdirAll(dirPath, 0o755)
|
|
require.NoError(t, err)
|
|
|
|
emptyFilePath := filepath.Join(tmpdir, "empty-file")
|
|
err = afero.WriteFile(fs, emptyFilePath, []byte(""), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
basicFilePath := filepath.Join(tmpdir, "basic-file")
|
|
err = afero.WriteFile(fs, basicFilePath, []byte("line1\nline2\nline3"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
longLine := string(bytes.Repeat([]byte("x"), 1025))
|
|
longLineFilePath := filepath.Join(tmpdir, "long-line-file")
|
|
err = afero.WriteFile(fs, longLineFilePath, []byte(longLine), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
largeFilePath := filepath.Join(tmpdir, "large-file")
|
|
err = afero.WriteFile(fs, largeFilePath, bytes.Repeat([]byte("x"), 1<<20+1), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
offset int64
|
|
limit int64
|
|
expSuccess bool
|
|
expError string
|
|
expContent string
|
|
expTotal int
|
|
expRead int
|
|
expSize int64
|
|
// useCodersdk is set for cases where the handler returns
|
|
// codersdk.Response (query param validation) instead of ReadFileLinesResponse.
|
|
useCodersdk bool
|
|
}{
|
|
{
|
|
name: "NoPath",
|
|
path: "",
|
|
useCodersdk: true,
|
|
expError: "is required",
|
|
},
|
|
{
|
|
name: "RelativePath",
|
|
path: "relative/path",
|
|
expError: "file path must be absolute",
|
|
},
|
|
{
|
|
name: "NonExistent",
|
|
path: filepath.Join(tmpdir, "does-not-exist"),
|
|
expError: "file does not exist",
|
|
},
|
|
{
|
|
name: "IsDir",
|
|
path: dirPath,
|
|
expError: "not a file",
|
|
},
|
|
{
|
|
name: "NoPermissions",
|
|
path: noPermsFilePath,
|
|
expError: "permission denied",
|
|
},
|
|
{
|
|
name: "EmptyFile",
|
|
path: emptyFilePath,
|
|
expSuccess: true,
|
|
expTotal: 0,
|
|
expRead: 0,
|
|
expSize: 0,
|
|
},
|
|
{
|
|
name: "BasicRead",
|
|
path: basicFilePath,
|
|
expSuccess: true,
|
|
expContent: "1\tline1\n2\tline2\n3\tline3",
|
|
expTotal: 3,
|
|
expRead: 3,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Offset2",
|
|
path: basicFilePath,
|
|
offset: 2,
|
|
expSuccess: true,
|
|
expContent: "2\tline2\n3\tline3",
|
|
expTotal: 3,
|
|
expRead: 2,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Limit1",
|
|
path: basicFilePath,
|
|
limit: 1,
|
|
expSuccess: true,
|
|
expContent: "1\tline1",
|
|
expTotal: 3,
|
|
expRead: 1,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "Offset2Limit1",
|
|
path: basicFilePath,
|
|
offset: 2,
|
|
limit: 1,
|
|
expSuccess: true,
|
|
expContent: "2\tline2",
|
|
expTotal: 3,
|
|
expRead: 1,
|
|
expSize: int64(len("line1\nline2\nline3")),
|
|
},
|
|
{
|
|
name: "OffsetBeyondFile",
|
|
path: basicFilePath,
|
|
offset: 100,
|
|
expError: "offset 100 is beyond the file length of 3 lines",
|
|
},
|
|
{
|
|
name: "LongLineTruncation",
|
|
path: longLineFilePath,
|
|
expSuccess: true,
|
|
expContent: "1\t" + string(bytes.Repeat([]byte("x"), 1024)) + "... [truncated]",
|
|
expTotal: 1,
|
|
expRead: 1,
|
|
expSize: 1025,
|
|
},
|
|
{
|
|
name: "LargeFile",
|
|
path: largeFilePath,
|
|
expError: "exceeds the maximum",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file-lines?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if tt.useCodersdk {
|
|
// Query param validation errors return codersdk.Response.
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
require.Contains(t, w.Body.String(), tt.expError)
|
|
return
|
|
}
|
|
|
|
var resp agentfiles.ReadFileLinesResponse
|
|
err := json.NewDecoder(w.Body).Decode(&resp)
|
|
require.NoError(t, err)
|
|
|
|
if tt.expSuccess {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.True(t, resp.Success)
|
|
require.Equal(t, tt.expContent, resp.Content)
|
|
require.Equal(t, tt.expTotal, resp.TotalLines)
|
|
require.Equal(t, tt.expRead, resp.LinesRead)
|
|
require.Equal(t, tt.expSize, resp.FileSize)
|
|
} else {
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.False(t, resp.Success)
|
|
require.Contains(t, resp.Error, tt.expError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteFile_FollowsSymlinks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
// Create a real file and a symlink pointing to it.
|
|
realPath := filepath.Join(dir, "real.txt")
|
|
err := afero.WriteFile(osFs, realPath, []byte("original"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
linkPath := filepath.Join(dir, "link.txt")
|
|
err = os.Symlink(realPath, linkPath)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
// Write through the symlink.
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
|
fmt.Sprintf("/write-file?path=%s", linkPath),
|
|
bytes.NewReader([]byte("updated")))
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// The symlink must still be a symlink.
|
|
fi, err := os.Lstat(linkPath)
|
|
require.NoError(t, err)
|
|
require.NotZero(t, fi.Mode()&os.ModeSymlink, "symlink was replaced")
|
|
|
|
// The real file must have the new content.
|
|
data, err := os.ReadFile(realPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "updated", string(data))
|
|
}
|
|
|
|
func TestEditFiles_FollowsSymlinks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
// Create a real file and a symlink pointing to it.
|
|
realPath := filepath.Join(dir, "real.txt")
|
|
err := afero.WriteFile(osFs, realPath, []byte("hello world"), 0o644)
|
|
require.NoError(t, err)
|
|
|
|
linkPath := filepath.Join(dir, "link.txt")
|
|
err = os.Symlink(realPath, linkPath)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
body := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: linkPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{
|
|
Search: "hello",
|
|
Replace: "goodbye",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err = enc.Encode(body)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
// The symlink must still be a symlink.
|
|
fi, err := os.Lstat(linkPath)
|
|
require.NoError(t, err)
|
|
require.NotZero(t, fi.Mode()&os.ModeSymlink, "symlink was replaced")
|
|
|
|
// The real file must have the edited content.
|
|
data, err := os.ReadFile(realPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "goodbye world", string(data))
|
|
}
|
|
|
|
func TestEditFiles_FileResults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
|
|
|
t.Run("DiffRequestedSingleFile", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "diff-single")
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte("hello world\n"), 0o644))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
IncludeDiff: true,
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "HELLO"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Len(t, resp.Files, 1)
|
|
require.Equal(t, path, resp.Files[0].Path)
|
|
// udiff.Unified emits "--- <path>\n+++ <path>\n@@ ...".
|
|
require.Contains(t, resp.Files[0].Diff, "--- "+path+"\n")
|
|
require.Contains(t, resp.Files[0].Diff, "+++ "+path+"\n")
|
|
require.Contains(t, resp.Files[0].Diff, "-hello world")
|
|
require.Contains(t, resp.Files[0].Diff, "+HELLO world")
|
|
})
|
|
|
|
t.Run("DiffRequestedNoOpEdit", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "diff-noop")
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte("same\n"), 0o644))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
IncludeDiff: true,
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
// Replace with identical text (no-op).
|
|
{Search: "same", Replace: "same"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Len(t, resp.Files, 1)
|
|
require.Equal(t, path, resp.Files[0].Path)
|
|
require.Empty(t, resp.Files[0].Diff, "no-op edit produces empty diff")
|
|
})
|
|
|
|
t.Run("DiffNotRequested", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "diff-off")
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte("hello\n"), 0o644))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
// IncludeDiff omitted; default false.
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "HELLO"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Nil(t, resp.Files, "Files must be nil when IncludeDiff is false")
|
|
})
|
|
|
|
t.Run("DiffRequestedMultiFilePreservesOrder", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
pathA := filepath.Join(tmpdir, "diff-multi-a")
|
|
pathB := filepath.Join(tmpdir, "diff-multi-b")
|
|
pathC := filepath.Join(tmpdir, "diff-multi-c")
|
|
require.NoError(t, afero.WriteFile(fs, pathA, []byte("A\n"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, pathB, []byte("B\n"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, pathC, []byte("C\n"), 0o644))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
IncludeDiff: true,
|
|
Files: []workspacesdk.FileEdits{
|
|
{Path: pathA, Edits: []workspacesdk.FileEdit{{Search: "A", Replace: "a"}}},
|
|
{Path: pathB, Edits: []workspacesdk.FileEdit{{Search: "B", Replace: "b"}}},
|
|
{Path: pathC, Edits: []workspacesdk.FileEdit{{Search: "C", Replace: "c"}}},
|
|
},
|
|
})
|
|
require.Len(t, resp.Files, 3)
|
|
expected := []struct {
|
|
path string
|
|
oldLine string
|
|
newLine string
|
|
}{
|
|
{pathA, "-A", "+a"},
|
|
{pathB, "-B", "+b"},
|
|
{pathC, "-C", "+c"},
|
|
}
|
|
for i, want := range expected {
|
|
require.Equal(t, want.path, resp.Files[i].Path)
|
|
require.NotEmpty(t, resp.Files[i].Diff, "file %d (%s) has empty diff", i, want.path)
|
|
require.Contains(t, resp.Files[i].Diff, want.oldLine)
|
|
require.Contains(t, resp.Files[i].Diff, want.newLine)
|
|
}
|
|
})
|
|
|
|
t.Run("DiffRequestedMultiEditSameFile", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "diff-multi-edit")
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte("one\ntwo\nthree\n"), 0o644))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
IncludeDiff: true,
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "one", Replace: "ONE"},
|
|
{Search: "three", Replace: "THREE"},
|
|
},
|
|
}},
|
|
})
|
|
require.Len(t, resp.Files, 1)
|
|
require.Equal(t, path, resp.Files[0].Path)
|
|
// Both edits must appear in the diff, computed against the
|
|
// file's original content (not the post-first-edit content).
|
|
require.Contains(t, resp.Files[0].Diff, "-one")
|
|
require.Contains(t, resp.Files[0].Diff, "+ONE")
|
|
require.Contains(t, resp.Files[0].Diff, "-three")
|
|
require.Contains(t, resp.Files[0].Diff, "+THREE")
|
|
})
|
|
t.Run("DiffRequestedSymlinkReportsOriginalPath", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks are not reliably supported on Windows")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
realPath := filepath.Join(dir, "real.txt")
|
|
require.NoError(t, afero.WriteFile(osFs, realPath, []byte("hello\n"), 0o644))
|
|
|
|
linkPath := filepath.Join(dir, "link.txt")
|
|
require.NoError(t, os.Symlink(realPath, linkPath))
|
|
|
|
resp := runEditFiles(t, api, workspacesdk.FileEditRequest{
|
|
IncludeDiff: true,
|
|
Files: []workspacesdk.FileEdits{
|
|
{
|
|
Path: linkPath,
|
|
Edits: []workspacesdk.FileEdit{
|
|
{Search: "hello", Replace: "HELLO"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.Len(t, resp.Files, 1)
|
|
// The response must report the caller-supplied path, not the
|
|
// symlink-resolved target.
|
|
require.Equal(t, linkPath, resp.Files[0].Path)
|
|
require.Contains(t, resp.Files[0].Diff, "--- "+linkPath+"\n")
|
|
require.Contains(t, resp.Files[0].Diff, "+++ "+linkPath+"\n")
|
|
})
|
|
}
|
|
|
|
// runEditFiles issues a single POST /edit-files call against api and
|
|
// decodes the success body into FileEditResponse. It requires a 200
|
|
// response; tests for error paths should decode the error shape
|
|
// directly.
|
|
func runEditFiles(t *testing.T, api *agentfiles.API, req workspacesdk.FileEditRequest) workspacesdk.FileEditResponse {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
var resp workspacesdk.FileEditResponse
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
|
return resp
|
|
}
|
|
|
|
// TestFuzzyReplace_EndingAndWhitespace exercises the line-endings
|
|
// and per-position whitespace behavior of the fuzzy matcher in
|
|
// both single-replace and replace-all modes.
|
|
//
|
|
// Match rule: content and search lines are compared after
|
|
// splitting off trailing (pass 2) or surrounding (pass 3)
|
|
// whitespace. The line ending is compared separately: identical,
|
|
// "\n" and "\r\n" are interchangeable, and an empty ending (EOF,
|
|
// no terminator on a line) matches any ending.
|
|
//
|
|
// Splice rule: for every matched line, the replacement's leading
|
|
// whitespace, trailing whitespace, and line ending are substituted
|
|
// with the matched content line's equivalents *when search and
|
|
// replace agree* at that position. Disagreement at a position
|
|
// means the caller wants to change that position explicitly, and
|
|
// the replacement's bytes win there.
|
|
//
|
|
// Pass 1 (byte-literal substring match) is untouched; tests that
|
|
// exercise it are noted.
|
|
func TestFuzzyReplace_EndingAndWhitespace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
replaceAll bool
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edits []edit
|
|
expected string
|
|
}{
|
|
// CRLF file, LF search: the ending rule lets "line\n"
|
|
// match "line\r\n"; the replacement is empty so the
|
|
// matched line is removed entirely.
|
|
{
|
|
name: "CRLF_Content_LFSearch_Delete",
|
|
content: "foo\r\nline\r\nbar\r\n",
|
|
edits: []edit{{search: "line\n", replace: ""}},
|
|
expected: "foo\r\nbar\r\n",
|
|
},
|
|
// Pass 2 tolerates the file's trailing whitespace on
|
|
// the matched line when search omits it. Empty
|
|
// replacement removes the line.
|
|
{
|
|
name: "TrailingWhitespace_Delete",
|
|
content: "foo\nline \nbar\n",
|
|
edits: []edit{{search: "line\n", replace: ""}},
|
|
expected: "foo\nbar\n",
|
|
},
|
|
// Pass 1 handles a search without a trailing newline
|
|
// when the content contains an exact substring match:
|
|
// strings.Replace preserves the surrounding "\n" bytes
|
|
// verbatim.
|
|
{
|
|
name: "Pass1_SearchNoNewline_ExactSubstring",
|
|
content: "foo\nfirst line\nbar\n",
|
|
edits: []edit{{search: "first line", replace: "LINE"}},
|
|
expected: "foo\nLINE\nbar\n",
|
|
},
|
|
// Fuzzy path, both search and replace lack a newline
|
|
// ending AND share a trailing space. The empty ending
|
|
// on search is a wildcard against content's "\n";
|
|
// pass 2's content comparator ignores the shared
|
|
// trailing space to match "key". At splice time,
|
|
// search and replace agree on the trailing space so
|
|
// the file's lack of trailing whitespace wins; search
|
|
// and replace agree on empty ending so the file's
|
|
// "\n" wins.
|
|
{
|
|
name: "FuzzyMatchingWhitespace_FileEndingWins",
|
|
content: "foo\nkey\nbar\n",
|
|
edits: []edit{{search: "key ", replace: "KEY "}},
|
|
expected: "foo\nKEY\nbar\n",
|
|
},
|
|
// Last-line-no-newline uses pass 1 exact match.
|
|
{
|
|
name: "Pass1_LastLineNoNewline",
|
|
content: "foo\nbar",
|
|
edits: []edit{{search: "bar", replace: "BAR"}},
|
|
expected: "foo\nBAR",
|
|
},
|
|
// Indent-tolerant matching on a CRLF file: search and
|
|
// replace disagree with the file on indent, so passes 1
|
|
// and 2 fail; pass 3 (TrimSpace) matches on body. The
|
|
// splice then decides each position by whether search
|
|
// and replace agree with each other. These three cases
|
|
// vary the caller-side whitespace to enumerate the
|
|
// mechanism:
|
|
//
|
|
// - when the caller agrees with itself on leading
|
|
// whitespace, the file's tab wins regardless of
|
|
// the space count on the caller side;
|
|
// - when the caller disagrees with itself (search
|
|
// leads with one thing, replace with another), the
|
|
// replacement's leading whitespace wins. That's the
|
|
// escape hatch for intentional indent rewrites.
|
|
//
|
|
// Endings always agree (both newline-class), so the
|
|
// file's "\r\n" wins at every emitted line.
|
|
{
|
|
name: "FuzzyIndent_CRLF_TwoSpaceSearch_FileTabWins",
|
|
content: "foo\r\n\tline\r\nbar\r\n",
|
|
edits: []edit{{search: " line\n", replace: " LINE\n"}},
|
|
expected: "foo\r\n\tLINE\r\nbar\r\n",
|
|
},
|
|
{
|
|
name: "FuzzyIndent_CRLF_SevenSpaceSearch_FileTabStillWins",
|
|
content: "foo\r\n\tline\r\nbar\r\n",
|
|
edits: []edit{{search: " line\n", replace: " LINE\n"}},
|
|
expected: "foo\r\n\tLINE\r\nbar\r\n",
|
|
},
|
|
{
|
|
name: "FuzzyIndent_CRLF_CallerRewritesIndent_ReplaceLeadingWins",
|
|
content: "foo\r\n\tline\r\nbar\r\n",
|
|
edits: []edit{{search: " line\n", replace: " LINE\n"}},
|
|
expected: "foo\r\n LINE\r\nbar\r\n",
|
|
},
|
|
|
|
// Replace-all must run through the same per-position
|
|
// splice as single-replace.
|
|
{
|
|
// Every matched line keeps the file's trailing
|
|
// whitespace shape (""), and its "\n" ending.
|
|
name: "ReplaceAll_FuzzyMatchingWhitespace_FileEndingWins",
|
|
content: "key\nkey\nother\n",
|
|
edits: []edit{{search: "key ", replace: "KEY ", replaceAll: true}},
|
|
expected: "KEY\nKEY\nother\n",
|
|
},
|
|
{
|
|
// CRLF file, LF search/replace: every splice uses
|
|
// the file's "\r\n" so the output is uniformly CRLF.
|
|
name: "ReplaceAll_CRLF_LFSearch_FileEndingWins",
|
|
content: "line one\r\nother\r\nline one\r\n",
|
|
edits: []edit{{search: "line one\n", replace: "LINE\n", replaceAll: true}},
|
|
expected: "LINE\r\nother\r\nLINE\r\n",
|
|
},
|
|
|
|
// Caller explicitly folds: the search has a newline
|
|
// ending, the replace omits it. Disagreement at the
|
|
// ending position means the replace's empty ending
|
|
// wins, so the next content line folds in. Pass 1
|
|
// handles this as a byte-literal match.
|
|
{
|
|
name: "CallerChosenFold",
|
|
content: "foo\nline\nbar\n",
|
|
edits: []edit{{search: "line\n", replace: "LINE"}},
|
|
expected: "foo\nLINEbar\n",
|
|
},
|
|
|
|
// Caller deliberately rewrites indent: search leads with
|
|
// a tab, replace leads with two spaces. Disagreement on
|
|
// the leading-whitespace position means the replacement's
|
|
// spaces win on the edited line. The untouched following
|
|
// line keeps its tab.
|
|
{
|
|
name: "CallerRewritesIndent_ReplaceLeadingWins",
|
|
content: "foo\n\tline\n\tbar\n",
|
|
edits: []edit{{search: "\tline\n", replace: " line\n"}},
|
|
expected: "foo\n line\n\tbar\n",
|
|
},
|
|
|
|
// Expansion: replace has more lines than the matched
|
|
// region. Extras reference the last paired search/content
|
|
// line, so an extra whose leading whitespace agrees with
|
|
// the last paired search line picks up the file's
|
|
// leading whitespace. Search uses 4 spaces to force the
|
|
// fuzzy path (pass 1 would splice verbatim).
|
|
{
|
|
name: "Expansion_ExtraLinesTrackLastPair",
|
|
content: "foo\n\tline\nbar\n",
|
|
edits: []edit{{search: " line\n", replace: " line\n extra\n"}},
|
|
expected: "foo\n\tline\n\textra\nbar\n",
|
|
},
|
|
|
|
// Collapse: replace has fewer lines than the matched
|
|
// region. Unpaired matched lines are consumed without
|
|
// output.
|
|
{
|
|
name: "Collapse_ReplaceShorterThanSearch",
|
|
content: "foo\nkeep\ndrop\nbar\n",
|
|
edits: []edit{{search: "keep\ndrop\n", replace: "keep\n"}},
|
|
expected: "foo\nkeep\nbar\n",
|
|
},
|
|
|
|
// Empty-ending wildcard: search has no trailing newline
|
|
// and leading whitespace that isn't in the file. Pass 1
|
|
// fails (the leading spaces aren't a substring). Pass 3
|
|
// (trim-all) matches. At the splice: search and replace
|
|
// both have empty endings, so endingShapeEqual agrees
|
|
// and the file's "\r\n" wins. The file's leading tab
|
|
// does not win because sLead=" " disagrees with
|
|
// rLead="", so the replacement's empty lead wins.
|
|
{
|
|
name: "EmptyEndingWildcard_CRLFContent_FileEndingWins",
|
|
content: "foo\r\nkey\r\nbar\r\n",
|
|
edits: []edit{{search: " key", replace: "KEY"}},
|
|
expected: "foo\r\nKEY\r\nbar\r\n",
|
|
},
|
|
|
|
// Multi-line replacement at EOF without trailing newline.
|
|
// The reference content line at the last index has
|
|
// cEnd="", but interior replacement lines must keep their
|
|
// "\n" rather than inherit the empty ending.
|
|
{
|
|
name: "MultiLineReplaceAtEOFNoNewline_InteriorLinesKeepNewline",
|
|
content: "foo\nbar",
|
|
edits: []edit{{search: "foo\nbar\n", replace: "foo\nbaz\nqux\n"}},
|
|
expected: "foo\nbaz\nqux",
|
|
},
|
|
|
|
// Empty replacement body must not inherit the file's
|
|
// surrounding whitespace. Search forces the fuzzy path
|
|
// via trimming; replace is a single blank line.
|
|
{
|
|
name: "EmptyBodyFuzzyReplace_NoWhitespaceGhost",
|
|
content: "prefix\n code \nsuffix\n",
|
|
edits: []edit{{search: "code\n", replace: "\n"}},
|
|
expected: "prefix\n\nsuffix\n",
|
|
},
|
|
|
|
// Combined: multi-line replacement at EOF without a
|
|
// newline, with an interior empty-body line. Exercises
|
|
// both carve-outs in one splice: the empty-body line
|
|
// must not inherit file whitespace, and interior lines
|
|
// must keep their newline even though the reference
|
|
// content line has cEnd="".
|
|
{
|
|
name: "EmptyBodyInteriorAtEOFNoNewline_BothCarveOuts",
|
|
content: "foo\nbar",
|
|
edits: []edit{{search: "foo\nbar\n", replace: "mid1\n\nmid2\n"}},
|
|
expected: "mid1\n\nmid2",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "fuzzy-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
|
|
for _, e := range tt.edits {
|
|
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
|
|
Search: e.search,
|
|
Replace: e.replace,
|
|
ReplaceAll: e.replaceAll,
|
|
})
|
|
}
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFuzzyReplace_EndingNormalization pins the line-ending rule.
|
|
//
|
|
// Rule: every spliced line gets the file's dominant ending, except
|
|
// when the caller signaled intent by making search and replace
|
|
// disagree on internal endings (both non-empty, different). Intent
|
|
// requires pass 1 to byte-match the file's endings; if it does,
|
|
// replace's endings are honored per-line. When only one side has
|
|
// internal endings (single-line vs. multi-line), the file wins.
|
|
//
|
|
// No-EOL at EOF is preserved: the final spliced line keeps its
|
|
// ending, so a match covering the file's last line does not
|
|
// materialize a newline the file never had.
|
|
func TestFuzzyReplace_EndingNormalization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
replaceAll bool
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edits []edit
|
|
expected string
|
|
}{
|
|
// CRLF file, LF search, LF replace with expansion.
|
|
// Internal endings agree (both LF), rule fires, every
|
|
// spliced line becomes CRLF.
|
|
{
|
|
name: "CRLFFile_LFSearchReplace_Expansion",
|
|
content: "line1\r\nline2\r\nline3\r\n",
|
|
edits: []edit{{search: "line1\nline2\n", replace: "line1\nINSERTED\nline2\n"}},
|
|
expected: "line1\r\nINSERTED\r\nline2\r\nline3\r\n",
|
|
},
|
|
// CRLF file with no trailing newline, LF search/replace
|
|
// with expansion that covers the file's last line. Interior
|
|
// spliced lines become CRLF; final spliced line preserves
|
|
// the file's no-EOL property.
|
|
{
|
|
name: "CRLFFileNoEOL_LFSearchReplace_ExpansionAtEOF",
|
|
content: "alpha\r\nbeta\r\ngamma",
|
|
edits: []edit{{search: "gamma", replace: "gamma\ndelta\nepsilon"}},
|
|
expected: "alpha\r\nbeta\r\ngamma\r\ndelta\r\nepsilon",
|
|
},
|
|
// CRLF Go file with no final newline; LLM sends LF
|
|
// search/replace that expands the function body. This is
|
|
// the motivating real-world case for the rule.
|
|
{
|
|
name: "CRLFFileNoEOL_LFCallerExpandsFunctionBody",
|
|
content: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n}",
|
|
edits: []edit{{search: "\tprintln(\"hi\")\n}", replace: "\tprintln(\"hi\")\n\tprintln(\"bye\")\n\treturn\n}"}},
|
|
expected: "package main\r\n\r\nfunc main() {\r\n\tprintln(\"hi\")\r\n\tprintln(\"bye\")\r\n\treturn\r\n}",
|
|
},
|
|
// LF file, CRLF search/replace (caller sent CRLF, file is
|
|
// LF). Internal endings agree (both CRLF). Rule fires, the
|
|
// file's LF wins.
|
|
{
|
|
name: "LFFile_CRLFSearchReplace_FileLFWins",
|
|
content: "one\ntwo\nthree\n",
|
|
edits: []edit{{search: "one\r\ntwo\r\n", replace: "ONE\r\nTWO\r\n"}},
|
|
expected: "ONE\nTWO\nthree\n",
|
|
},
|
|
// Caller got endings right: CRLF in search, replace, and file.
|
|
// Pins that normalization doesn't regress this happy path.
|
|
{
|
|
name: "CRLFFile_CRLFSearchReplace_SanityPreserved",
|
|
content: "a\r\nb\r\nc\r\n",
|
|
edits: []edit{{search: "a\r\nb\r\n", replace: "A\r\nB\r\n"}},
|
|
expected: "A\r\nB\r\nc\r\n",
|
|
},
|
|
// ReplaceAll with expansion on a CRLF file via LF caller.
|
|
// Every spliced region must be CRLF throughout.
|
|
{
|
|
name: "ReplaceAll_CRLFFile_LFCaller_Expansion",
|
|
content: "key\r\nother\r\nkey\r\n",
|
|
edits: []edit{{
|
|
search: "key\n",
|
|
replace: "KEY\nEXTRA\n",
|
|
replaceAll: true,
|
|
}},
|
|
expected: "KEY\r\nEXTRA\r\nother\r\nKEY\r\nEXTRA\r\n",
|
|
},
|
|
// Caller sent CRLF search and LF replace against a CRLF
|
|
// file. Different ending styles between search and replace
|
|
// signal caller intent to change endings. Search's CRLF
|
|
// byte-matches the file's CRLF, so the match succeeds and
|
|
// replace's LF endings are honored per-line. The untouched
|
|
// trailing line keeps its CRLF.
|
|
{
|
|
name: "CallerIntent_SearchMatchesFile_ReplaceEndingsHonored",
|
|
content: "x\r\ny\r\nz\r\n",
|
|
edits: []edit{{search: "x\r\ny\r\n", replace: "X\nY\n"}},
|
|
expected: "X\nY\nz\r\n",
|
|
},
|
|
// Single-line search against a CRLF file, multi-line
|
|
// replace. Search has no endings, so no caller intent is
|
|
// signaled and the file's CRLF wins for every spliced line.
|
|
{
|
|
name: "SingleLineSearch_MultiLineReplace_FileEndingWins",
|
|
content: "a\r\nx\r\nb\r\n",
|
|
edits: []edit{{search: "x", replace: "X\nY"}},
|
|
expected: "a\r\nX\r\nY\r\nb\r\n",
|
|
},
|
|
// Trivial baseline: neither side has endings, nothing to
|
|
// normalize.
|
|
{
|
|
name: "SingleLineSearch_SingleLineReplace_NoEndingsToNormalize",
|
|
content: "a\r\nx\r\nb\r\n",
|
|
edits: []edit{{search: "x", replace: "X"}},
|
|
expected: "a\r\nX\r\nb\r\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "endnorm-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
|
|
for _, e := range tt.edits {
|
|
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
|
|
Search: e.search,
|
|
Replace: e.replace,
|
|
ReplaceAll: e.replaceAll,
|
|
})
|
|
}
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFuzzyReplace_FuzzyCollapse_PreservesNextLine pins that a
|
|
// shorter replacement under the fuzzy path does not merge the
|
|
// next unmatched content line onto the last spliced line.
|
|
func TestFuzzyReplace_FuzzyCollapse_PreservesNextLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edits []edit
|
|
expected string
|
|
}{
|
|
// Minimal: tab-indented file, space-indented caller
|
|
// forces pass 3, replace has fewer lines than search.
|
|
{
|
|
name: "Minimal",
|
|
content: "\tone\n\ttwo\n\tthree\n\tafter\n",
|
|
edits: []edit{{
|
|
search: " one\n two\n three\n",
|
|
replace: " ONE\n TWO\n",
|
|
}},
|
|
expected: "\tONE\n\tTWO\n\tafter\n",
|
|
},
|
|
// The adversarial harness's reproduction from
|
|
// coderd/httpapi/httpapi.go, inline: the original had
|
|
// `return valid == nil` on its own line after the
|
|
// matched region. The bug merged it onto the last
|
|
// replacement line with a tab separator.
|
|
{
|
|
name: "HarnessHttpapi",
|
|
content: "\tnameValidator := func(fl validator.FieldLevel) bool {\n" +
|
|
"\t\tf := fl.Field().Interface()\n" +
|
|
"\t\tstr, ok := f.(string)\n" +
|
|
"\t\tif !ok {\n" +
|
|
"\t\t\treturn false\n" +
|
|
"\t\t}\n" +
|
|
"\t\tvalid := codersdk.NameValid(str)\n" +
|
|
"\t\treturn valid == nil\n" +
|
|
"\t}\n",
|
|
edits: []edit{{
|
|
search: " f := fl.Field().Interface()\n" +
|
|
" str, ok := f.(string)\n" +
|
|
" if !ok {\n" +
|
|
" return false\n" +
|
|
" }\n" +
|
|
" valid := codersdk.NameValid(str)",
|
|
replace: " f := fl.Field().Interface()\n" +
|
|
" str, _ := f.(string)\n" +
|
|
" valid := codersdk.NameValid(str)",
|
|
}},
|
|
expected: "\tnameValidator := func(fl validator.FieldLevel) bool {\n" +
|
|
"\t\tf := fl.Field().Interface()\n" +
|
|
"\t\tstr, _ := f.(string)\n" +
|
|
"\t\tvalid := codersdk.NameValid(str)\n" +
|
|
"\t\treturn valid == nil\n" +
|
|
"\t}\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "fuzzycollapse-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
|
|
for _, e := range tt.edits {
|
|
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
|
|
Search: e.search,
|
|
Replace: e.replace,
|
|
})
|
|
}
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEditFiles_WhitespaceAndLineEndings covers whitespace and
|
|
// line-ending behaviors end-to-end through the HTTP handler,
|
|
// complementing the matcher-focused TestFuzzyReplace_EndingAndWhitespace.
|
|
// Each case has a short comment describing the behavior it pins.
|
|
func TestEditFiles_WhitespaceAndLineEndings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
search, replace string
|
|
replaceAll bool
|
|
expected string // empty => expect an error response
|
|
errSub string
|
|
}{
|
|
// Tab-indented file, search matches one tab-indented
|
|
// line byte-for-byte via pass 1. Tabs on untouched
|
|
// lines remain; untouched space-indented lines remain.
|
|
{
|
|
name: "TabIndentedLine_ExactMatch",
|
|
content: "\ttab indented line 1\n\ttab indented line 2\n spaces line 3\n spaces line 4\n\ttab indented line 5\n",
|
|
search: "\ttab indented line 1",
|
|
replace: "\ttab indented line 1 EDITED",
|
|
expected: "\ttab indented line 1 EDITED\n\ttab indented line 2\n" +
|
|
" spaces line 3\n spaces line 4\n\ttab indented line 5\n",
|
|
},
|
|
|
|
// Trailing whitespace on the content line is preserved
|
|
// via pass 1 (byte-substring match) because the search
|
|
// is a proper substring that doesn't touch the trailing
|
|
// whitespace.
|
|
{
|
|
name: "TrailingWhitespace_Preserved_ByPass1",
|
|
content: "line with trailing spaces \nno trailing ws\n",
|
|
search: "line with trailing spaces",
|
|
replace: "line with trailing spaces EDITED",
|
|
expected: "line with trailing spaces EDITED \nno trailing ws\n",
|
|
},
|
|
|
|
// File has two blank lines between "above" and "below";
|
|
// search omits them. Fuzzy passes also reject because
|
|
// the search spans fewer lines than the content does,
|
|
// so blank lines are preserved significant content.
|
|
{
|
|
name: "BlankLinesAreSignificant_Rejects",
|
|
content: "above\n\n\nbelow\n",
|
|
search: "above\nbelow",
|
|
replace: "above\nbelow",
|
|
errSub: "search string not found",
|
|
},
|
|
|
|
// Search matches blank lines exactly; replacement
|
|
// collapses the region.
|
|
{
|
|
name: "RemoveBlankLines",
|
|
content: "above\n\n\nbelow\n",
|
|
search: "above\n\n\nbelow",
|
|
replace: "above\nbelow",
|
|
expected: "above\nbelow\n",
|
|
},
|
|
|
|
// CRLF file, pass 1 substring match preserves "\r\n"
|
|
// boundaries on every line.
|
|
{
|
|
name: "CRLF_Pass1_PreservesCRLF",
|
|
content: "line one\r\nline two\r\nline three\r\n",
|
|
search: "line two",
|
|
replace: "line two EDITED",
|
|
expected: "line one\r\nline two EDITED\r\nline three\r\n",
|
|
},
|
|
|
|
// CRLF file, LF search and replace. The ending rule
|
|
// accepts the match, and the splice rule promotes the
|
|
// replacement's LF endings to the file's "\r\n"
|
|
// because search and replace agree on ending shape.
|
|
{
|
|
name: "CRLF_FuzzyWithLF_FileEndingWins",
|
|
content: "line one\r\nline two\r\nline three\r\n",
|
|
search: "line one\nline two\n",
|
|
replace: "line one EDITED\nline two EDITED\n",
|
|
expected: "line one EDITED\r\nline two EDITED\r\nline three\r\n",
|
|
},
|
|
|
|
// File has no trailing newline; pass 1 preserves EOF
|
|
// shape.
|
|
{
|
|
name: "NoTrailingNewline_Preserved",
|
|
content: "no trailing newline",
|
|
search: "no trailing newline",
|
|
replace: "no trailing newline EDITED",
|
|
expected: "no trailing newline EDITED",
|
|
},
|
|
|
|
// Tab-indented content, space-indented search and
|
|
// replace. Pass 3 matches the line body ignoring
|
|
// leading whitespace. Search and replace agree on
|
|
// leading whitespace (both " ") so the file's "\t"
|
|
// wins; search and replace agree on ending (both
|
|
// "\n") so the file's "\n" wins. The following
|
|
// "\titem two\n" is not folded into the replacement.
|
|
{
|
|
name: "FuzzyIndent_FileIndentWins_NoLineFolding",
|
|
content: "\titem one\n\titem two\n",
|
|
search: " item one\n",
|
|
replace: " item one EDITED\n",
|
|
expected: "\titem one EDITED\n\titem two\n",
|
|
},
|
|
}
|
|
|
|
for _, ct := range cases {
|
|
t.Run(ct.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "ws-"+ct.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(ct.content), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: ct.search,
|
|
Replace: ct.replace,
|
|
ReplaceAll: ct.replaceAll,
|
|
}},
|
|
}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
if ct.errSub != "" {
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
|
got := &codersdk.Error{}
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
|
|
require.ErrorContains(t, got, ct.errSub)
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ct.content, string(data))
|
|
return
|
|
}
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ct.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFuzzyReplace_Rejects pins the cases the matcher rejects, so
|
|
// regressions that weaken the guardrails get caught. Each case runs
|
|
// through the HTTP handler; the handler must return 400 with an
|
|
// error message matching errSub, and the file must be unchanged.
|
|
//
|
|
// Rejection sources:
|
|
//
|
|
// - Empty search (meaningful search text is required; the old
|
|
// behavior matched at every byte position when combined with
|
|
// replace_all).
|
|
// - Ambiguous match without replace_all (N > 1 occurrences of the
|
|
// search text).
|
|
// - Search not found in file (after all three passes fail).
|
|
// - Content mismatch that cannot be recovered by trimming
|
|
// whitespace on either side.
|
|
// - Blank-line count mismatch inside the matched region.
|
|
func TestFuzzyReplace_Rejects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
replaceAll bool
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edits []edit
|
|
errSub string
|
|
}{
|
|
// Empty search with replace_all=false: reject to prevent
|
|
// the ambiguous "prepend at byte 0" behavior.
|
|
{
|
|
name: "EmptySearch_Rejects",
|
|
content: "hello\n",
|
|
edits: []edit{{search: "", replace: "X"}},
|
|
errSub: "search string must not be empty",
|
|
},
|
|
// Empty search with replace_all=true: historically
|
|
// injected the replacement between every byte, silently
|
|
// corrupting the file. Reject explicitly.
|
|
{
|
|
name: "EmptySearch_ReplaceAll_Rejects",
|
|
content: "hello\n",
|
|
edits: []edit{{search: "", replace: "X", replaceAll: true}},
|
|
errSub: "search string must not be empty",
|
|
},
|
|
// Ambiguous single-replace: 3 distinct matches, caller
|
|
// did not ask for replace_all.
|
|
{
|
|
name: "Ambiguous_SingleReplace_Rejects",
|
|
content: "a\na\na\nother\n",
|
|
edits: []edit{{search: "a", replace: "A"}},
|
|
errSub: "matches 3 occurrences",
|
|
},
|
|
// Search text does not appear anywhere in the file. All
|
|
// three passes miss.
|
|
{
|
|
name: "NotFound_Rejects",
|
|
content: "hello\nworld\n",
|
|
edits: []edit{{search: "nonexistent\n", replace: "X\n"}},
|
|
errSub: "search string not found",
|
|
},
|
|
// Content mismatch that trimming cannot recover: search
|
|
// has different letters, not just different whitespace.
|
|
{
|
|
name: "ContentMismatch_Rejects",
|
|
content: "hello\n",
|
|
edits: []edit{{search: "Hello\n", replace: "HELLO\n"}},
|
|
errSub: "search string not found",
|
|
},
|
|
// Blank lines in the file that the search omits: the
|
|
// fuzzy window cannot align against the blank lines, so
|
|
// the multi-line match fails.
|
|
{
|
|
name: "BlankLineMismatch_Rejects",
|
|
content: "above\n\n\nbelow\n",
|
|
edits: []edit{{search: "above\nbelow\n", replace: "above\nbelow\n"}},
|
|
errSub: "search string not found",
|
|
},
|
|
// Search/replace disagreement signals intent to rewrite
|
|
// endings; search must byte-match the file's. LF search
|
|
// against CRLF file fails pass 1 and must reject rather
|
|
// than fall through to pass 2's CRLF/LF interchange.
|
|
{
|
|
name: "CallerIntent_SearchDoesNotMatchFileEnding_Rejects",
|
|
content: "x\r\ny\r\nz\r\n",
|
|
edits: []edit{{search: "x\ny\n", replace: "X\r\nY\r\n"}},
|
|
errSub: "search string not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "reject-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
sdkEdits := make([]workspacesdk.FileEdit, 0, len(tt.edits))
|
|
for _, e := range tt.edits {
|
|
sdkEdits = append(sdkEdits, workspacesdk.FileEdit{
|
|
Search: e.search,
|
|
Replace: e.replace,
|
|
ReplaceAll: e.replaceAll,
|
|
})
|
|
}
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{Path: path, Edits: sdkEdits}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
|
got := &codersdk.Error{}
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
|
|
require.ErrorContains(t, got, tt.errSub)
|
|
|
|
// File must not have been modified by any partial
|
|
// splice or write.
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.content, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEditFiles_DuplicatePath_Merges verifies that duplicate paths in
|
|
// one request are merged: edits from all entries for the same path are
|
|
// concatenated and applied in order.
|
|
func TestEditFiles_DuplicatePath_Merges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "dup-path")
|
|
original := "one\ntwo\nthree\n"
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(original), 0o644))
|
|
|
|
// Entry 2 searches for the output of entry 1, proving edits
|
|
// are applied in the order they appear across entries.
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "CHANGED"}}},
|
|
{Path: path, Edits: []workspacesdk.FileEdit{{Search: "CHANGED", Replace: "FINAL"}}},
|
|
},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "FINAL\ntwo\nthree\n", string(data))
|
|
}
|
|
|
|
// TestEditFiles_DuplicatePath_NonCanonicalMerges verifies that
|
|
// non-canonical paths normalizing to the same file are merged,
|
|
// not rejected as symlink aliases.
|
|
func TestEditFiles_DuplicatePath_NonCanonicalMerges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
canonical := filepath.Join(tmpdir, "noncanon")
|
|
nonCanonical := canonical[:len(tmpdir)] + "/./noncanon"
|
|
original := "one\ntwo\nthree\n"
|
|
require.NoError(t, afero.WriteFile(fs, canonical, []byte(original), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{Path: canonical, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "ONE"}}},
|
|
{Path: nonCanonical, Edits: []workspacesdk.FileEdit{{Search: "three", Replace: "THREE"}}},
|
|
},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
data, err := afero.ReadFile(fs, canonical)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ONE\ntwo\nTHREE\n", string(data))
|
|
}
|
|
|
|
// TestEditFiles_DuplicatePath_SymlinkAliasRejects pins that two
|
|
// request entries pointing to the same real file (one direct, one
|
|
// via a symlink) are rejected. Without resolve-before-dedup, the
|
|
// raw-path check lets both entries through, and the second write
|
|
// silently overwrites the first.
|
|
func TestEditFiles_DuplicatePath_SymlinkAliasRejects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks are not reliably supported on Windows")
|
|
}
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
dir := t.TempDir()
|
|
osFs := afero.NewOsFs()
|
|
api := agentfiles.NewAPI(logger, osFs, nil)
|
|
|
|
realPath := filepath.Join(dir, "real.txt")
|
|
original := "one\ntwo\nthree\n"
|
|
require.NoError(t, afero.WriteFile(osFs, realPath, []byte(original), 0o644))
|
|
|
|
linkPath := filepath.Join(dir, "link.txt")
|
|
require.NoError(t, os.Symlink(realPath, linkPath))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{
|
|
{Path: realPath, Edits: []workspacesdk.FileEdit{{Search: "one", Replace: "ONE"}}},
|
|
{Path: linkPath, Edits: []workspacesdk.FileEdit{{Search: "three", Replace: "THREE"}}},
|
|
},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
|
got := &codersdk.Error{}
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
|
|
require.ErrorContains(t, got, "aliases")
|
|
|
|
// File on disk must be untouched: the alias collision is caught
|
|
// before phase 1 so no write runs.
|
|
data, err := afero.ReadFile(osFs, realPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, original, string(data))
|
|
}
|
|
|
|
// TestEditFiles_ReplaceAll_FuzzyIndentGap locks the CURRENT output
|
|
// of a known foot-gun, it doesn't bless it.
|
|
//
|
|
// Gap: replace_all plus a pass-3 (indent-agnostic) match hits every
|
|
// nesting level whose body matches after TrimSpace. A caller aiming
|
|
// at one block silently edits the same pattern at other depths.
|
|
// The per-position splice preserves each match's local indent, so
|
|
// the output is syntactically fine. The foot-gun is that wrong
|
|
// SITES get edited.
|
|
//
|
|
// The right fix is a caller-side opt-out from fuzzy matching, out
|
|
// of scope for this PR. When that lands, update the test to assert
|
|
// the new behavior.
|
|
func TestEditFiles_ReplaceAll_FuzzyIndentGap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "replaceall-fuzzyindent-gap")
|
|
|
|
// File is tab-indented Go, with `if err != nil { return err }`
|
|
// at two nesting levels (2 tabs and 3 tabs). Caller sends a
|
|
// 4-space-indented search/replace pair with replace_all=true.
|
|
// Pass 1 fails (no 4-space prefix in file). Pass 2 fails (trim
|
|
// right doesn't touch leading whitespace). Pass 3 (TrimSpace)
|
|
// matches at BOTH depths. Current behavior: replace both.
|
|
content := "package main\n\nfunc a() {\n" +
|
|
"\t\tif err != nil {\n" +
|
|
"\t\t\treturn err\n" +
|
|
"\t\t}\n" +
|
|
"\t\t\tif err != nil {\n" +
|
|
"\t\t\t\treturn err\n" +
|
|
"\t\t\t}\n" +
|
|
"}\n"
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: " if err != nil {\n" +
|
|
" return err\n" +
|
|
" }\n",
|
|
Replace: " if err != nil {\n" +
|
|
" return fmt.Errorf(\"wrap: %w\", err)\n" +
|
|
" }\n",
|
|
ReplaceAll: true,
|
|
}},
|
|
}},
|
|
}
|
|
|
|
_ = runEditFiles(t, api, req)
|
|
|
|
// Both depths got edited. The per-position splice preserved each
|
|
// site's local indent, so output is syntactically fine, just
|
|
// edited at two places, only one of which the caller likely
|
|
// intended.
|
|
expected := "package main\n\nfunc a() {\n" +
|
|
"\t\tif err != nil {\n" +
|
|
"\t\t\treturn fmt.Errorf(\"wrap: %w\", err)\n" +
|
|
"\t\t}\n" +
|
|
"\t\t\tif err != nil {\n" +
|
|
"\t\t\t\treturn fmt.Errorf(\"wrap: %w\", err)\n" +
|
|
"\t\t\t}\n" +
|
|
"}\n"
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, string(data))
|
|
}
|
|
|
|
// TestEditFiles_FuzzyIndent_InsertionLevelAware covers indent-
|
|
// propagation bugs that fire when the caller's search/replace
|
|
// whitespace differs from the file's (tab vs space, 2sp vs 4sp).
|
|
//
|
|
// - Red_* cases assert the correct output that the indent-unit
|
|
// translation produces for inserted splice lines.
|
|
// - Lock_* cases pin output for middle-substitution scenarios
|
|
// that the insertion-only fix does not cover; tracked in
|
|
// CODAGT-214.
|
|
func TestEditFiles_FuzzyIndent_InsertionLevelAware(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
replaceAll bool
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edits []edit
|
|
expected string
|
|
}{
|
|
// Wrap an existing line in a new block. Tab file, 4sp caller.
|
|
{
|
|
name: "Red_WrapInBlock_TabFile_4spLLM",
|
|
content: "func main() {\n" +
|
|
"\tfmt.Println(\"hello\")\n" +
|
|
"\tfmt.Println(\"world\")\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " fmt.Println(\"hello\")\n" +
|
|
" fmt.Println(\"world\")",
|
|
replace: " fmt.Println(\"hello\")\n" +
|
|
" if verbose {\n" +
|
|
" fmt.Println(\"world\")\n" +
|
|
" }",
|
|
}},
|
|
expected: "func main() {\n" +
|
|
"\tfmt.Println(\"hello\")\n" +
|
|
"\tif verbose {\n" +
|
|
"\t\tfmt.Println(\"world\")\n" +
|
|
"\t}\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Wrap in a new block, 2sp file, 4sp caller. The common
|
|
// real-world trigger: Claude/GPT default 4sp into a 2sp file.
|
|
{
|
|
name: "Red_WrapInBlock_2spFile_4spLLM",
|
|
content: "function main() {\n" +
|
|
" console.log('hello')\n" +
|
|
" console.log('world')\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " console.log('hello')\n" +
|
|
" console.log('world')",
|
|
replace: " console.log('hello')\n" +
|
|
" if (verbose) {\n" +
|
|
" console.log('world')\n" +
|
|
" }",
|
|
}},
|
|
expected: "function main() {\n" +
|
|
" console.log('hello')\n" +
|
|
" if (verbose) {\n" +
|
|
" console.log('world')\n" +
|
|
" }\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Expand a single line into an error-handling block.
|
|
{
|
|
name: "Red_SingleToMulti_ErrorHandling",
|
|
content: "func main() {\n" +
|
|
"\tx := getValue()\n" +
|
|
"\tfmt.Println(x)\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " x := getValue()",
|
|
replace: " x, err := getValue()\n" +
|
|
" if err != nil {\n" +
|
|
" log.Fatal(err)\n" +
|
|
" }",
|
|
}},
|
|
expected: "func main() {\n" +
|
|
"\tx, err := getValue()\n" +
|
|
"\tif err != nil {\n" +
|
|
"\t\tlog.Fatal(err)\n" +
|
|
"\t}\n" +
|
|
"\tfmt.Println(x)\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Insert a new validation block after an existing if-block.
|
|
{
|
|
name: "Red_InsertNewBlock_AfterExisting",
|
|
content: "func loadConfig() (*Config, error) {\n" +
|
|
"\tvar cfg Config\n" +
|
|
"\terr = json.Unmarshal(data, \u0026cfg)\n" +
|
|
"\tif err != nil {\n" +
|
|
"\t\treturn nil, err\n" +
|
|
"\t}\n" +
|
|
"\n" +
|
|
"\treturn \u0026cfg, nil\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " var cfg Config\n" +
|
|
" err = json.Unmarshal(data, \u0026cfg)\n" +
|
|
" if err != nil {\n" +
|
|
" return nil, err\n" +
|
|
" }\n" +
|
|
"\n" +
|
|
" return \u0026cfg, nil",
|
|
replace: " var cfg Config\n" +
|
|
" err = json.Unmarshal(data, \u0026cfg)\n" +
|
|
" if err != nil {\n" +
|
|
" return nil, fmt.Errorf(\"unmarshal: %w\", err)\n" +
|
|
" }\n" +
|
|
" if err := cfg.Validate(); err != nil {\n" +
|
|
" return nil, fmt.Errorf(\"validate: %w\", err)\n" +
|
|
" }\n" +
|
|
"\n" +
|
|
" return \u0026cfg, nil",
|
|
}},
|
|
expected: "func loadConfig() (*Config, error) {\n" +
|
|
"\tvar cfg Config\n" +
|
|
"\terr = json.Unmarshal(data, \u0026cfg)\n" +
|
|
"\tif err != nil {\n" +
|
|
"\t\treturn nil, fmt.Errorf(\"unmarshal: %w\", err)\n" +
|
|
"\t}\n" +
|
|
"\tif err := cfg.Validate(); err != nil {\n" +
|
|
"\t\treturn nil, fmt.Errorf(\"validate: %w\", err)\n" +
|
|
"\t}\n" +
|
|
"\n" +
|
|
"\treturn \u0026cfg, nil\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// replace_all + pass 3 + expansion at two sites.
|
|
{
|
|
name: "Red_ReplaceAll_Pass3_Expansion",
|
|
content: "func handlers() {\n" +
|
|
"\thttp.HandleFunc(\"/a\", func(w http.ResponseWriter, r *http.Request) {\n" +
|
|
"\t\tdata := readBody(r)\n" +
|
|
"\t\tprocess(data)\n" +
|
|
"\t})\n" +
|
|
"\thttp.HandleFunc(\"/b\", func(w http.ResponseWriter, r *http.Request) {\n" +
|
|
"\t\tdata := readBody(r)\n" +
|
|
"\t\tprocess(data)\n" +
|
|
"\t})\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " data := readBody(r)\n" +
|
|
" process(data)",
|
|
replace: " data := readBody(r)\n" +
|
|
" if data == nil {\n" +
|
|
" return\n" +
|
|
" }\n" +
|
|
" process(data)",
|
|
replaceAll: true,
|
|
}},
|
|
expected: "func handlers() {\n" +
|
|
"\thttp.HandleFunc(\"/a\", func(w http.ResponseWriter, r *http.Request) {\n" +
|
|
"\t\tdata := readBody(r)\n" +
|
|
"\t\tif data == nil {\n" +
|
|
"\t\t\treturn\n" +
|
|
"\t\t}\n" +
|
|
"\t\tprocess(data)\n" +
|
|
"\t})\n" +
|
|
"\thttp.HandleFunc(\"/b\", func(w http.ResponseWriter, r *http.Request) {\n" +
|
|
"\t\tdata := readBody(r)\n" +
|
|
"\t\tif data == nil {\n" +
|
|
"\t\t\treturn\n" +
|
|
"\t\t}\n" +
|
|
"\t\tprocess(data)\n" +
|
|
"\t})\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Unwrap (decrease nesting). All output lines are
|
|
// middle-substitutions; CODAGT-214 covers the fix.
|
|
{
|
|
name: "Lock_Unwrap_MiddleSubDisagreement",
|
|
content: "func main() {\n" +
|
|
"\tif condition {\n" +
|
|
"\t\tdoSomething()\n" +
|
|
"\t\tdoMore()\n" +
|
|
"\t}\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " if condition {\n" +
|
|
" doSomething()\n" +
|
|
" doMore()\n" +
|
|
" }",
|
|
replace: " doSomething()\n" +
|
|
" doMore()",
|
|
}},
|
|
// Line 2 leaks 4 literal spaces (middle-sub disagreement
|
|
// rule: rLead wins when sLead != rLead).
|
|
expected: "func main() {\n" +
|
|
"\tdoSomething()\n" +
|
|
" doMore()\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Middle-rewrite with different nesting, tab file. Mixed
|
|
// fate: inserted lines fixed, middle-subs still leak.
|
|
{
|
|
name: "Lock_MiddleRewrite_DifferentNesting_Tab",
|
|
content: "func transform(items []Item) []Result {\n" +
|
|
"\tvar results []Result\n" +
|
|
"\tfor _, item := range items {\n" +
|
|
"\t\tif item.Valid {\n" +
|
|
"\t\t\tresults = append(results, convert(item))\n" +
|
|
"\t\t}\n" +
|
|
"\t}\n" +
|
|
"\treturn results\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " var results []Result\n" +
|
|
" for _, item := range items {\n" +
|
|
" if item.Valid {\n" +
|
|
" results = append(results, convert(item))\n" +
|
|
" }\n" +
|
|
" }\n" +
|
|
" return results",
|
|
replace: " var results []Result\n" +
|
|
" for _, item := range items {\n" +
|
|
" result, err := convert(item)\n" +
|
|
" if err != nil {\n" +
|
|
" continue\n" +
|
|
" }\n" +
|
|
" results = append(results, result)\n" +
|
|
" }\n" +
|
|
" return results",
|
|
}},
|
|
// Middle-sub lines (i=3, i=4) leak literal 8sp/12sp;
|
|
// the inserted } and append lines are tab-correct.
|
|
expected: "func transform(items []Item) []Result {\n" +
|
|
"\tvar results []Result\n" +
|
|
"\tfor _, item := range items {\n" +
|
|
"\t\tresult, err := convert(item)\n" +
|
|
" if err != nil {\n" +
|
|
" continue\n" +
|
|
"\t\t}\n" +
|
|
"\t\tresults = append(results, result)\n" +
|
|
"\t}\n" +
|
|
"\treturn results\n" +
|
|
"}\n",
|
|
},
|
|
|
|
// Same class as lock #7, 2sp file (JS/TS).
|
|
{
|
|
name: "Lock_MiddleRewrite_DifferentNesting_2sp",
|
|
content: "function transform(items) {\n" +
|
|
" const results = [];\n" +
|
|
" for (const item of items) {\n" +
|
|
" if (item.valid) {\n" +
|
|
" results.push(convert(item));\n" +
|
|
" }\n" +
|
|
" }\n" +
|
|
" return results;\n" +
|
|
"}\n",
|
|
edits: []edit{{
|
|
search: " const results = [];\n" +
|
|
" for (const item of items) {\n" +
|
|
" if (item.valid) {\n" +
|
|
" results.push(convert(item));\n" +
|
|
" }\n" +
|
|
" }\n" +
|
|
" return results;",
|
|
replace: " const results = [];\n" +
|
|
" for (const item of items) {\n" +
|
|
" const result = convert(item);\n" +
|
|
" if (!result) {\n" +
|
|
" continue;\n" +
|
|
" }\n" +
|
|
" results.push(result);\n" +
|
|
" }\n" +
|
|
" return results;",
|
|
}},
|
|
// Middle-sub lines (i=3, i=4) leak 8sp/12sp; the inserted
|
|
// } and push lines translate to 4sp correctly.
|
|
expected: "function transform(items) {\n" +
|
|
" const results = [];\n" +
|
|
" for (const item of items) {\n" +
|
|
" const result = convert(item);\n" +
|
|
" if (!result) {\n" +
|
|
" continue;\n" +
|
|
" }\n" +
|
|
" results.push(result);\n" +
|
|
" }\n" +
|
|
" return results;\n" +
|
|
"}\n",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "fuzzyindent-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: make([]workspacesdk.FileEdit, 0, len(tt.edits)),
|
|
}},
|
|
}
|
|
for _, e := range tt.edits {
|
|
req.Files[0].Edits = append(req.Files[0].Edits, workspacesdk.FileEdit{
|
|
Search: e.search,
|
|
Replace: e.replace,
|
|
ReplaceAll: e.replaceAll,
|
|
})
|
|
}
|
|
|
|
_ = runEditFiles(t, api, req)
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFuzzyReplace_Expansion_PreservesFileIndent pins that when
|
|
// replace has more lines than search, every spliced line keeps
|
|
// the file's indent style. Inserted lines especially must not
|
|
// carry the caller's literal whitespace into the output.
|
|
func TestFuzzyReplace_Expansion_PreservesFileIndent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "fuzzy-expansion-gap")
|
|
|
|
content := "\tnameValidator := func(fl validator.FieldLevel) bool {\n" +
|
|
"\t\tf := fl.Field().Interface()\n" +
|
|
"\t\tstr, ok := f.(string)\n" +
|
|
"\t\tif !ok {\n" +
|
|
"\t\t\treturn false\n" +
|
|
"\t\t}\n" +
|
|
"\t\tvalid := codersdk.NameValid(str)\n" +
|
|
"\t\treturn valid == nil\n" +
|
|
"\t}\n"
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: " f := fl.Field().Interface()\n" +
|
|
" str, ok := f.(string)\n" +
|
|
" if !ok {\n" +
|
|
" return false\n" +
|
|
" }\n" +
|
|
" valid := codersdk.NameValid(str)",
|
|
Replace: " f := fl.Field().Interface()\n" +
|
|
" str, ok := f.(string)\n" +
|
|
" if !ok {\n" +
|
|
" log.Println(\"type assertion failed\")\n" +
|
|
" return false\n" +
|
|
" }\n" +
|
|
" valid := codersdk.NameValid(str)",
|
|
}},
|
|
}},
|
|
}
|
|
|
|
_ = runEditFiles(t, api, req)
|
|
|
|
// All lines emitted in the file's tab indent, including the
|
|
// inserted log.Println and the following return false (which
|
|
// index-pairs with a different search line but shares the same
|
|
// 3-tab depth in the file).
|
|
expected := "\tnameValidator := func(fl validator.FieldLevel) bool {\n" +
|
|
"\t\tf := fl.Field().Interface()\n" +
|
|
"\t\tstr, ok := f.(string)\n" +
|
|
"\t\tif !ok {\n" +
|
|
"\t\t\tlog.Println(\"type assertion failed\")\n" +
|
|
"\t\t\treturn false\n" +
|
|
"\t\t}\n" +
|
|
"\t\tvalid := codersdk.NameValid(str)\n" +
|
|
"\t\treturn valid == nil\n" +
|
|
"\t}\n"
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, string(data))
|
|
}
|
|
|
|
// baseFuzzyNotFoundMessage is the leading sentence the matcher
|
|
// returns when all three passes miss. It must remain the leading
|
|
// sentence even when diagnostic hints are appended, so existing log
|
|
// scrapers continue to match.
|
|
const baseFuzzyNotFoundMessage = "search string not found in file. " +
|
|
"Verify the search string matches the file content exactly, " +
|
|
"including whitespace and indentation"
|
|
|
|
// TestFuzzyReplace_Hints exercises the post-fail diagnostic hints:
|
|
// inversion (search and replace swapped) and miscount (one repeated
|
|
// rune at the wrong count). Each detector lists every match it finds
|
|
// and truncates the output to five entries with " and N more".
|
|
func TestFuzzyReplace_Hints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpdir := os.TempDir()
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
|
|
|
type edit struct {
|
|
search, replace string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
edit edit
|
|
wantSubs []string
|
|
notWantSubs []string
|
|
}{
|
|
{
|
|
name: "Inversion_HintIncludesSwapAndLine",
|
|
content: "package main\n" +
|
|
"\n" +
|
|
"func adder(a int, b int) int { return a + b }\n" +
|
|
"\n" +
|
|
"// trailing comment\n",
|
|
edit: edit{
|
|
search: "func adder(a, b int) int {\n\treturn a + b\n}\n",
|
|
replace: "func adder(a int, b int) int { return a + b }\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 3`,
|
|
},
|
|
},
|
|
{
|
|
name: "Inversion_ThreeAnchors_AllListed",
|
|
content: "a\n" +
|
|
"matching block body of substantial length\n" +
|
|
"b\n" +
|
|
"matching block body of substantial length\n" +
|
|
"c\n" +
|
|
"matching block body of substantial length\n" +
|
|
"d\n",
|
|
edit: edit{
|
|
search: "this search text is absent from the file\n",
|
|
replace: "matching block body of substantial length\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 2, 4, 6`,
|
|
},
|
|
notWantSubs: []string{"more"},
|
|
},
|
|
{
|
|
name: "Inversion_SevenAnchors_TruncatedWithAndMore",
|
|
content: "matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n" +
|
|
"matching block body of substantial length\n",
|
|
edit: edit{
|
|
search: "this search text is absent from the file\n",
|
|
replace: "matching block body of substantial length\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 1, 2, 3, 4, 5 and 2 more`,
|
|
},
|
|
},
|
|
{
|
|
name: "Inversion_ShortReplace_TruncatedWithAndMore",
|
|
// Short replace strings used to be silently suppressed by
|
|
// a length floor. Now the line-list cap signals "your
|
|
// replace is too generic" by showing five matches plus
|
|
// " and N more", which is more informative than no hint.
|
|
content: "alpha\nbeta\nbeta\nbeta\nbeta\nbeta\nbeta\nbeta\ngamma\n",
|
|
edit: edit{
|
|
search: "missing line that does not occur anywhere\n",
|
|
replace: "beta\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 2, 3, 4, 5, 6 and 2 more`,
|
|
},
|
|
},
|
|
{
|
|
name: "Miscount_BoxDrawingDashes_HintNamesCodepoint",
|
|
content: "<header>\n" +
|
|
"{/* SECTION HEADING " + strings.Repeat("\u2500", 37) + " */}\n" +
|
|
"<body/>\n",
|
|
edit: edit{
|
|
search: "{/* SECTION HEADING " + strings.Repeat("\u2500", 32) + " */}\n",
|
|
replace: "{/* REPLACED */}\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
"Your search has 32 \"\u2500\" (U+2500); the file has 37 at line 2",
|
|
},
|
|
},
|
|
{
|
|
name: "Miscount_ASCIIEquals_HintWorks",
|
|
content: "title\n" +
|
|
"section =======\n" +
|
|
"body\n",
|
|
edit: edit{
|
|
search: "section =====\n",
|
|
replace: "section *****\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Your search has 5 "=" (U+003D); the file has 7 at line 2`,
|
|
},
|
|
},
|
|
{
|
|
name: "Miscount_TwoCandidates_BothListed",
|
|
content: "section =======\n" +
|
|
"section ===\n",
|
|
edit: edit{
|
|
search: "section =====\n",
|
|
replace: "section *****\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Your search has 5 "=" (U+003D); the file has 7 at line 1, 3 at line 2`,
|
|
},
|
|
notWantSubs: []string{"more"},
|
|
},
|
|
{
|
|
name: "Miscount_SixCandidates_TruncatedWithAndMore",
|
|
content: "section ==\n" +
|
|
"section ===\n" +
|
|
"section ======\n" +
|
|
"section =======\n" +
|
|
"section ========\n" +
|
|
"section =========\n",
|
|
edit: edit{
|
|
search: "section =====\n",
|
|
replace: "section *****\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Your search has 5 "=" (U+003D); the file has 2 at line 1, 3 at line 2, 6 at line 3, 7 at line 4, 8 at line 5 and 1 more`,
|
|
},
|
|
},
|
|
{
|
|
name: "Miscount_TwoDistinctChanges_NoHint",
|
|
content: "first\n" +
|
|
"a===b\n" +
|
|
"last\n",
|
|
edit: edit{
|
|
search: "a=====b!\n",
|
|
replace: "unused\n",
|
|
},
|
|
wantSubs: []string{baseFuzzyNotFoundMessage},
|
|
notWantSubs: []string{"Your search has", "the file has"},
|
|
},
|
|
{
|
|
name: "Miscount_Unrelated_NoHint",
|
|
content: "package foo\n\nfunc bar() {}\n",
|
|
edit: edit{
|
|
search: "this content is wholly different from the file\n",
|
|
replace: "unused\n",
|
|
},
|
|
wantSubs: []string{baseFuzzyNotFoundMessage},
|
|
notWantSubs: []string{"Your search has", "the file has"},
|
|
},
|
|
{
|
|
name: "Miscount_SuppressesInversion_WhenBothCouldFire",
|
|
content: "<header>\n" +
|
|
"{/* SECTION HEADING " + strings.Repeat("\u2500", 8) + " */}\n" +
|
|
"<body>\n" +
|
|
"doSomethingWithLongName(ctx)\n" +
|
|
"</body>\n",
|
|
edit: edit{
|
|
// Search has 6 dashes (miscount target on line 2).
|
|
search: "{/* SECTION HEADING " + strings.Repeat("\u2500", 6) + " */}\n",
|
|
// Replace is unrelated text that happens to appear at
|
|
// line 4. Without miscount-takes-precedence, the
|
|
// inversion hint would direct an agent to swap and
|
|
// corrupt line 4.
|
|
replace: "doSomethingWithLongName(ctx)\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
"Your search has 6 \"\u2500\" (U+2500); the file has 8 at line 2",
|
|
},
|
|
notWantSubs: []string{"swap", "appears at line"},
|
|
},
|
|
{
|
|
name: "Inversion_DedupRepeatsOnOneLine",
|
|
content: "prefix\n" +
|
|
"AAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAA\n" +
|
|
"suffix\n",
|
|
edit: edit{
|
|
search: "absent search line not in file at all\n",
|
|
replace: "AAAAAAAAAAAAAAAAAAAA\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 2`,
|
|
},
|
|
// Line 2 must appear once, not 2, 2, 2.
|
|
notWantSubs: []string{"line 2, 2", "more"},
|
|
},
|
|
{
|
|
name: "Inversion_TrimRightFallback_TrailingSpaces",
|
|
// Content line has trailing spaces; replace omits them.
|
|
// Byte-substring misses; trimRight line-equivalent
|
|
// matches.
|
|
content: "preamble\n" +
|
|
"matching block body of substantial length \n" +
|
|
"trailer\n",
|
|
edit: edit{
|
|
search: "absent search line not in file at all\n",
|
|
replace: "matching block body of substantial length\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 2`,
|
|
},
|
|
},
|
|
{
|
|
name: "Inversion_TrimAllFallback_LeadingIndent",
|
|
// Content line has leading indentation that replace
|
|
// omits. Byte-substring misses; trim-right also misses
|
|
// (the leading whitespace is on a different side);
|
|
// trim-all matches.
|
|
content: "preamble\n" +
|
|
"\t\tmatching block body of substantial length\n" +
|
|
"trailer\n",
|
|
edit: edit{
|
|
search: "absent search line not in file at all\n",
|
|
replace: "matching block body of substantial length\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 2`,
|
|
},
|
|
},
|
|
{
|
|
name: "Miscount_SingleRuneDiff_Suppressed",
|
|
// Rune `b` differs (sc=1, cc=0). Both counts < 2, the
|
|
// suppression guard fires, no hint.
|
|
content: "first\nxa\nlast\n",
|
|
edit: edit{
|
|
search: "xab\n",
|
|
replace: "unused\n",
|
|
},
|
|
wantSubs: []string{baseFuzzyNotFoundMessage},
|
|
notWantSubs: []string{"Your search has", "the file has"},
|
|
},
|
|
{
|
|
name: "Miscount_TotalHintsCapped",
|
|
// Four search lines, each matching a distinct file line
|
|
// via a distinct miscount rune. With maxMiscountHints=3,
|
|
// only 3 hint sentences appear plus " and 1 more".
|
|
content: "section ==\n" +
|
|
"divider ++\n" +
|
|
"line ##\n" +
|
|
"header @@\n",
|
|
edit: edit{
|
|
search: "section ====\n" +
|
|
"divider ++++\n" +
|
|
"line ####\n" +
|
|
"header @@@@\n",
|
|
replace: "unused\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Your search has 4 "=" (U+003D)`,
|
|
`Your search has 4 "+" (U+002B)`,
|
|
`Your search has 4 "#" (U+0023)`,
|
|
"and 1 more",
|
|
},
|
|
// The fourth hint (`@`) is suppressed by the cap.
|
|
notWantSubs: []string{`"@"`},
|
|
},
|
|
{
|
|
name: "Inversion_OverlappingMultilineMatch",
|
|
// Self-overlapping multi-line replace: "A\nB\nA\n"
|
|
// starts at line 1 and line 3 of the file. The old
|
|
// non-overlapping advancement missed line 3.
|
|
content: "AAAAAAAAAAAAAAAAAAAA\n" +
|
|
"BBBBBBBBBBBBBBBBBBBB\n" +
|
|
"AAAAAAAAAAAAAAAAAAAA\n" +
|
|
"BBBBBBBBBBBBBBBBBBBB\n" +
|
|
"AAAAAAAAAAAAAAAAAAAA\n",
|
|
edit: edit{
|
|
search: "absent search line not in file at all\n",
|
|
replace: "AAAAAAAAAAAAAAAAAAAA\n" +
|
|
"BBBBBBBBBBBBBBBBBBBB\n" +
|
|
"AAAAAAAAAAAAAAAAAAAA\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Did you swap "search" and "replace"? Your replace string appears at line 1, 3`,
|
|
},
|
|
notWantSubs: []string{"more"},
|
|
},
|
|
{
|
|
name: "Miscount_RuneOnlyInFile",
|
|
// Disagreeing rune `b` appears only in the file line.
|
|
// Exercises the second loop of singleRuneCountMismatch
|
|
// (runes in c but absent from s).
|
|
content: "section ==bb\n",
|
|
edit: edit{
|
|
search: "section ==\n",
|
|
replace: "section --\n",
|
|
},
|
|
wantSubs: []string{
|
|
baseFuzzyNotFoundMessage,
|
|
`Your search has 0 "b" (U+0062); the file has 2 at line 1`,
|
|
},
|
|
},
|
|
{
|
|
name: "NoHints_BaseErrorOnly",
|
|
content: "package foo\n" +
|
|
"\n" +
|
|
"func bar() {}\n",
|
|
edit: edit{
|
|
search: "func zzzz() {}\n",
|
|
replace: "new\n",
|
|
},
|
|
wantSubs: []string{baseFuzzyNotFoundMessage},
|
|
notWantSubs: []string{"swap", "Your search has", "appears at line"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
api := agentfiles.NewAPI(logger, fs, nil)
|
|
path := filepath.Join(tmpdir, "hint-"+tt.name)
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(tt.content), 0o644))
|
|
|
|
req := workspacesdk.FileEditRequest{
|
|
Files: []workspacesdk.FileEdits{{
|
|
Path: path,
|
|
Edits: []workspacesdk.FileEdit{{
|
|
Search: tt.edit.search,
|
|
Replace: tt.edit.replace,
|
|
}},
|
|
}},
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
require.NoError(t, enc.Encode(req))
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
|
api.Routes().ServeHTTP(w, r)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
|
|
got := &codersdk.Error{}
|
|
require.NoError(t, json.NewDecoder(w.Body).Decode(got))
|
|
msg := got.Message
|
|
for _, sub := range tt.wantSubs {
|
|
require.Contains(t, msg, sub, "want substring missing")
|
|
}
|
|
for _, sub := range tt.notWantSubs {
|
|
require.NotContains(t, msg, sub, "unwanted substring present")
|
|
}
|
|
|
|
data, err := afero.ReadFile(fs, path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.content, string(data))
|
|
})
|
|
}
|
|
}
|