chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="FileEditor"/> helper that backs the <c>replace</c> and
|
||||
/// <c>replace_lines</c> tools.
|
||||
/// </summary>
|
||||
public class FileEditorTests
|
||||
{
|
||||
#region ApplyReplace
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplace_SingleOccurrence_ReplacesAndReturnsCount()
|
||||
{
|
||||
// Act
|
||||
(string content, int count) = FileEditor.ApplyReplace("Hello world", "world", "there", replaceAll: false);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Hello there", content);
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplace_ReplaceAll_ReplacesEveryOccurrence()
|
||||
{
|
||||
// Act
|
||||
(string content, int count) = FileEditor.ApplyReplace("a a a", "a", "b", replaceAll: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("b b b", content);
|
||||
Assert.Equal(3, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplace_EmptyOldString_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("content", string.Empty, "x", replaceAll: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplace_NotFound_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("content", "missing", "x", replaceAll: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplace_MultipleOccurrences_WithoutReplaceAll_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplace("a a a", "a", "b", replaceAll: false));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ApplyReplaceLines
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_ReplacesSpecifiedLine()
|
||||
{
|
||||
// Act — new_line is literal; the caller supplies the trailing newline to keep it.
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2\nline3",
|
||||
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "CHANGED\n" } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("line1\nCHANGED\nline3", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_EmptyEdits_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines("line1", new List<FileLineEdit>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_OutOfRange_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2",
|
||||
new List<FileLineEdit> { new() { LineNumber = 5, NewLine = "X" } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_DuplicateLineNumber_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2",
|
||||
new List<FileLineEdit>
|
||||
{
|
||||
new() { LineNumber = 1, NewLine = "A" },
|
||||
new() { LineNumber = 1, NewLine = "B" },
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_LiteralNewLineControlsTrailingNewline()
|
||||
{
|
||||
// Act — the literal new_line keeps the trailing newline the caller provides.
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2\n",
|
||||
new List<FileLineEdit> { new() { LineNumber = 1, NewLine = "CHANGED\n" } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CHANGED\nline2\n", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_PreservesCrlfWhenCallerSuppliesIt()
|
||||
{
|
||||
// Act — a CRLF file keeps CRLF endings when the caller supplies "\r\n".
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"line1\r\nline2\r\nline3",
|
||||
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "CHANGED\r\n" } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("line1\r\nCHANGED\r\nline3", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_EmptyNewLine_DeletesMiddleLine()
|
||||
{
|
||||
// Act — an empty new_line removes the line, including its line break.
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2\nline3\n",
|
||||
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = string.Empty } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("line1\nline3\n", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_EmptyNewLine_DeletesLastLineWithoutTerminator()
|
||||
{
|
||||
// Act
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"line1\nline2",
|
||||
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = string.Empty } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("line1\n", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_DeleteAndReplaceInSameCall()
|
||||
{
|
||||
// Act
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"a\nb\nc\n",
|
||||
new List<FileLineEdit>
|
||||
{
|
||||
new() { LineNumber = 1, NewLine = string.Empty },
|
||||
new() { LineNumber = 3, NewLine = "C\n" },
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("b\nC\n", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyReplaceLines_EmbeddedNewLine_ExpandsIntoMultipleLines()
|
||||
{
|
||||
// Act — a literal new_line may contain its own newlines to insert extra lines.
|
||||
string result = FileEditor.ApplyReplaceLines(
|
||||
"a\nb\nc\n",
|
||||
new List<FileLineEdit> { new() { LineNumber = 2, NewLine = "b1\nb2\n" } });
|
||||
|
||||
// Assert
|
||||
Assert.Equal("a\nb1\nb2\nc\n", result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1007
File diff suppressed because it is too large
Load Diff
+658
@@ -0,0 +1,658 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class InMemoryAgentFileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAndReadFile_ReturnsContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteAsync("notes.md", "Hello world");
|
||||
var content = await store.ReadAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Hello world", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_NonExistent_ReturnsNullAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var content = await store.ReadAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.Null(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_OverwritesExistingAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Original");
|
||||
|
||||
// Act
|
||||
await store.WriteAsync("notes.md", "Updated");
|
||||
var content = await store.ReadAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Updated", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Content");
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteAsync("notes.md");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await store.ReadAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var deleted = await store.DeleteAsync("nonexistent.md");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_ReturnsDirectChildrenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/file1.md", "Content 1");
|
||||
await store.WriteAsync("folder/file2.md", "Content 2");
|
||||
await store.WriteAsync("folder/sub/file3.md", "Content 3");
|
||||
await store.WriteAsync("other/file4.md", "Content 4");
|
||||
|
||||
// Act
|
||||
var files = (await store.ListChildrenAsync("folder"))
|
||||
.Where(e => e.Type == FileStoreEntry.File)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("file1.md", files);
|
||||
Assert.Contains("file2.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_EmptyDirectory_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
var files = (await store.ListChildrenAsync("empty"))
|
||||
.Where(e => e.Type == FileStoreEntry.File)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_RootDirectory_ReturnsRootFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("root.md", "Content");
|
||||
await store.WriteAsync("folder/nested.md", "Content");
|
||||
|
||||
// Act
|
||||
var files = (await store.ListChildrenAsync(""))
|
||||
.Where(e => e.Type == FileStoreEntry.File)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(files);
|
||||
Assert.Equal("root.md", files[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_IncludesDescriptionFilesAsync()
|
||||
{
|
||||
// Arrange — the store is dumb; it returns all files including _description.md
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Content");
|
||||
await store.WriteAsync("folder/notes_description.md", "Desc");
|
||||
|
||||
// Act
|
||||
var files = (await store.ListChildrenAsync("folder"))
|
||||
.Where(e => e.Type == FileStoreEntry.File)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains("notes.md", files);
|
||||
Assert.Contains("notes_description.md", files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_ExistingFile_ReturnsTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Content");
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(await store.FileExistsAsync("notes.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileExists_NonExistent_ReturnsFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(await store.FileExistsAsync("nonexistent.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_FindsMatchingContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "The quick brown fox jumps over the lazy dog");
|
||||
await store.WriteAsync("folder/other.md", "No match here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "brown fox");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
Assert.Contains("brown fox", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_ReturnsMatchingLineNumbersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Line one\nLine two with match\nLine three\nLine four with match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(2, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal("Line two with match", results[0].MatchingLines[0].Line);
|
||||
Assert.Equal(4, results[0].MatchingLines[1].LineNumber);
|
||||
Assert.Equal("Line four with match", results[0].MatchingLines[1].Line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_CaseInsensitiveAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Important Data Here");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "important data");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexPatternAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Error: something went wrong\nWarning: check this\nInfo: all good");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "error|warning");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
Assert.Equal(1, results[0].MatchingLines[0].LineNumber);
|
||||
Assert.Equal(2, results[0].MatchingLines[1].LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_SupportsRegexWithSpecialCharactersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/code.cs", "var x = 42;\nvar y = 100;\nconst z = 7;");
|
||||
|
||||
// Act — regex matching lines starting with "var"
|
||||
var results = await store.SearchAsync("folder", @"^var\b");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal(2, results[0].MatchingLines.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_FiltersFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Important data");
|
||||
await store.WriteAsync("folder/data.txt", "Important data");
|
||||
await store.WriteAsync("folder/code.cs", "Important data");
|
||||
|
||||
// Act — only search markdown files
|
||||
var results = await store.SearchAsync("folder", "Important", globPattern: "*.md");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_MultipleExtensionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "match here");
|
||||
await store.WriteAsync("folder/data.txt", "match here");
|
||||
await store.WriteAsync("folder/code.cs", "match here");
|
||||
|
||||
// Act — search both md and txt files
|
||||
var resultsMd = await store.SearchAsync("folder", "match", globPattern: "*.md");
|
||||
var resultsTxt = await store.SearchAsync("folder", "match", globPattern: "*.txt");
|
||||
|
||||
// Assert
|
||||
Assert.Single(resultsMd);
|
||||
Assert.Equal("notes.md", resultsMd[0].FileName);
|
||||
Assert.Single(resultsTxt);
|
||||
Assert.Equal("data.txt", resultsTxt[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithGlobPattern_PrefixMatchAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/research_ai.md", "findings");
|
||||
await store.WriteAsync("folder/research_ml.md", "findings");
|
||||
await store.WriteAsync("folder/notes.md", "findings");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "findings", globPattern: "research*");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.StartsWith("research", r.FileName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_WithNullGlobPattern_SearchesAllFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "match");
|
||||
await store.WriteAsync("folder/data.txt", "match");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "match", globPattern: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_NoMatch_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Some content");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "nonexistent query");
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_IgnoresSubdirectoryFilesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("folder/notes.md", "Match here");
|
||||
await store.WriteAsync("folder/sub/deep.md", "Match here too");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "Match");
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Equal("notes.md", results[0].FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_IncludesSurroundingContextAsync()
|
||||
{
|
||||
// Arrange — place the match in the middle of a long line so ±50 chars are available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string padding = new('A', 60);
|
||||
string content = $"{padding}MATCH_HERE{padding}";
|
||||
await store.WriteAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "MATCH_HERE");
|
||||
|
||||
// Assert — snippet should contain the match and surrounding context (up to ±50 chars).
|
||||
Assert.Single(results);
|
||||
string snippet = results[0].Snippet;
|
||||
Assert.Contains("MATCH_HERE", snippet);
|
||||
Assert.True(snippet.Length <= 50 + "MATCH_HERE".Length + 50, "Snippet should be at most ±50 chars around the match.");
|
||||
Assert.True(snippet.Length > "MATCH_HERE".Length, "Snippet should include surrounding context.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearStartOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very beginning, so no leading context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string trailing = new('B', 80);
|
||||
string content = $"MATCH{trailing}";
|
||||
await store.WriteAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should start at the beginning of the file.
|
||||
Assert.Single(results);
|
||||
Assert.StartsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= "MATCH".Length + 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_MatchNearEndOfFileAsync()
|
||||
{
|
||||
// Arrange — match is at the very end, so no trailing context is available.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string leading = new('C', 80);
|
||||
string content = $"{leading}MATCH";
|
||||
await store.WriteAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "MATCH");
|
||||
|
||||
// Assert — snippet should end at the end of the file.
|
||||
Assert.Single(results);
|
||||
Assert.EndsWith("MATCH", results[0].Snippet);
|
||||
Assert.True(results[0].Snippet.Length <= 50 + "MATCH".Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_UsesFirstMatchPositionAsync()
|
||||
{
|
||||
// Arrange — "target" appears on lines 1 and 3, but the regex only matches line 3
|
||||
// because we require the word "UNIQUE" which only appears on line 3.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
const string Content = "Line one has some text\nLine two is filler\nLine three has UNIQUE_MARKER here";
|
||||
await store.WriteAsync("folder/file.md", Content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "UNIQUE_MARKER");
|
||||
|
||||
// Assert — snippet should be from around line 3, not line 1.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("UNIQUE_MARKER", results[0].Snippet);
|
||||
Assert.Contains("Line three", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Snippet_CorrectForMultiLineMatchAsync()
|
||||
{
|
||||
// Arrange — match is on the second line with enough distance from line 1
|
||||
// that the ±50 char snippet window does not reach the start of the file.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
string line1 = new('X', 100);
|
||||
string line2 = new string('Y', 60) + "FIND_ME" + new string('Z', 60);
|
||||
string line3 = new('W', 100);
|
||||
string content = $"{line1}\n{line2}\n{line3}";
|
||||
await store.WriteAsync("folder/file.md", content);
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("folder", "FIND_ME");
|
||||
|
||||
// Assert — snippet should contain the match from line 2.
|
||||
Assert.Single(results);
|
||||
Assert.Contains("FIND_ME", results[0].Snippet);
|
||||
|
||||
// The match is at offset 101 (line1=100 + '\n') + 60 = 161.
|
||||
// snippetStart = 161 - 50 = 111, which is well past line 1 (ends at offset 100).
|
||||
// So line 1 content should not appear in the snippet.
|
||||
Assert.DoesNotContain("XXXX", results[0].Snippet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathNormalization_HandlesBackslashesAndTrailingSlashesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteAsync("folder\\file.md", "Content");
|
||||
var content = await store.ReadAsync("folder/file.md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("../escape.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadFile_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ReadAsync("folder/../../escape.md"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_AbsolutePath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("/etc/passwd", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DoubleDotsInFileName_AllowedAsync()
|
||||
{
|
||||
// Arrange — "notes..md" contains ".." as a substring but not as a path segment.
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act
|
||||
await store.WriteAsync("notes..md", "Content");
|
||||
var content = await store.ReadAsync("notes..md");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Content", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteFile_DriveRootedPath_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.WriteAsync("C:\\temp\\file.md", "Content"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFiles_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ListChildrenAsync("../other"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDirectories_PathTraversal_ThrowsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => store.ListChildrenAsync("../other"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Recursive_FindsDescendantsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Match here");
|
||||
await store.WriteAsync("reports/q1.md", "Match here too");
|
||||
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("", "Match", globPattern: null, recursive: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, results.Count);
|
||||
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
|
||||
Assert.Equal("notes.md,reports/2024/q2.md,reports/q1.md", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Recursive_GlobScopesToSubtreeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Match here");
|
||||
await store.WriteAsync("reports/q1.md", "Match here too");
|
||||
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("", "Match", globPattern: "reports/**", recursive: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
|
||||
Assert.Equal("reports/2024/q2.md,reports/q1.md", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFiles_Recursive_GlobMatchesNestedExtensionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("notes.md", "Match here");
|
||||
await store.WriteAsync("reports/q1.txt", "Match here too");
|
||||
await store.WriteAsync("reports/2024/q2.md", "Match here as well");
|
||||
|
||||
// Act
|
||||
var results = await store.SearchAsync("", "Match", globPattern: "**/*.md", recursive: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
var names = string.Join(",", results.Select(r => r.FileName).OrderBy(n => n, StringComparer.Ordinal));
|
||||
Assert.Equal("notes.md,reports/2024/q2.md", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDirectories_ReturnsDirectChildSubdirectoriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("root.md", "x");
|
||||
await store.WriteAsync("reports/q1.md", "x");
|
||||
await store.WriteAsync("reports/2024/q2.md", "x");
|
||||
await store.WriteAsync("images/logo.png", "x");
|
||||
|
||||
// Act
|
||||
var directories = (await store.ListChildrenAsync(""))
|
||||
.Where(e => e.Type == FileStoreEntry.Directory)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
|
||||
Assert.Equal("images,reports", sorted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDirectories_NestedDirectory_ReturnsChildrenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("reports/q1.md", "x");
|
||||
await store.WriteAsync("reports/2024/q2.md", "x");
|
||||
await store.WriteAsync("reports/2025/q3.md", "x");
|
||||
|
||||
// Act
|
||||
var directories = (await store.ListChildrenAsync("reports"))
|
||||
.Where(e => e.Type == FileStoreEntry.Directory)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
var sorted = string.Join(",", directories.OrderBy(d => d, StringComparer.Ordinal));
|
||||
Assert.Equal("2024,2025", sorted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListDirectories_NoSubdirectories_ReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryAgentFileStore();
|
||||
await store.WriteAsync("root.md", "x");
|
||||
|
||||
// Act
|
||||
var directories = (await store.ListChildrenAsync(""))
|
||||
.Where(e => e.Type == FileStoreEntry.Directory)
|
||||
.Select(e => e.Name)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(directories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.FileSystemGlobbing;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory;
|
||||
|
||||
public class StorePathsTests
|
||||
{
|
||||
#region NormalizeRelativePath — valid paths
|
||||
|
||||
[Theory]
|
||||
[InlineData("file.md", "file.md")]
|
||||
[InlineData("folder/file.md", "folder/file.md")]
|
||||
[InlineData("a/b/c.txt", "a/b/c.txt")]
|
||||
public void NormalizeRelativePath_ValidPath_ReturnsNormalized(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("folder\\file.md", "folder/file.md")]
|
||||
[InlineData("a\\b\\c.txt", "a/b/c.txt")]
|
||||
public void NormalizeRelativePath_Backslashes_NormalizesToForwardSlash(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("folder//file.md", "folder/file.md")]
|
||||
[InlineData("a///b////c.txt", "a/b/c.txt")]
|
||||
public void NormalizeRelativePath_ConsecutiveSeparators_Collapsed(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath(input);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRelativePath_TrailingSlash_Trimmed()
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath("file.md/");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("file.md", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/file.md")]
|
||||
[InlineData("/folder/file.md/")]
|
||||
public void NormalizeRelativePath_LeadingSlash_Throws(string input)
|
||||
{
|
||||
// Act & Assert — leading slash is treated as a rooted path.
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeRelativePath — rejected paths
|
||||
|
||||
[Theory]
|
||||
[InlineData("../file.md")]
|
||||
[InlineData("folder/../file.md")]
|
||||
[InlineData("./file.md")]
|
||||
[InlineData("folder/./file.md")]
|
||||
public void NormalizeRelativePath_TraversalSegments_Throws(string input)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C:\\file.md")]
|
||||
[InlineData("C:/file.md")]
|
||||
[InlineData("D:file.md")]
|
||||
public void NormalizeRelativePath_DriveRoot_Throws(string input)
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRelativePath_EmptyFile_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRelativePath_WhitespaceOnlyFile_Throws()
|
||||
{
|
||||
// Act & Assert — whitespace-only paths are rejected as invalid file names.
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath(" "));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeRelativePath — directory mode
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRelativePath_EmptyDirectory_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath("", isDirectory: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("folder", "folder")]
|
||||
[InlineData("a/b", "a/b")]
|
||||
[InlineData("a\\b/", "a/b")]
|
||||
public void NormalizeRelativePath_DirectoryMode_NormalizesPath(string input, string expected)
|
||||
{
|
||||
// Act
|
||||
string result = StorePaths.NormalizeRelativePath(input, isDirectory: true);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeRelativePath_DirectoryTraversal_Throws()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => StorePaths.NormalizeRelativePath("../folder", isDirectory: true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateGlobMatcher and MatchesGlob
|
||||
|
||||
[Theory]
|
||||
[InlineData("*.md", "notes.md", true)]
|
||||
[InlineData("*.md", "notes.txt", false)]
|
||||
[InlineData("research*", "research_results.md", true)]
|
||||
[InlineData("research*", "notes.md", false)]
|
||||
[InlineData("*.md", "NOTES.MD", true)] // case-insensitive
|
||||
public void MatchesGlob_WithMatcher_MatchesCorrectly(string pattern, string fileName, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
Matcher matcher = StorePaths.CreateGlobMatcher(pattern);
|
||||
|
||||
// Act
|
||||
bool result = StorePaths.MatchesGlob(fileName, matcher);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesGlob_NullMatcher_ReturnsTrue()
|
||||
{
|
||||
// Act
|
||||
bool result = StorePaths.MatchesGlob("anything.txt", null);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user