Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions tests/Orchestrator.App.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
Expand All @@ -13,6 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
</ItemGroup>

<ItemGroup>
Expand Down
106 changes: 106 additions & 0 deletions tests/TestHelpers/MockWorkContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using Moq;
using Orchestrator.App;

namespace Orchestrator.App.Tests.TestHelpers;

internal static class MockWorkContext
{
public static WorkContext Create(
WorkItem? workItem = null,
OctokitGitHubClient? github = null,
OrchestratorConfig? config = null,
RepoWorkspace? workspace = null,
RepoGit? repo = null,
LlmClient? llm = null)
{
workItem ??= CreateWorkItem();
github ??= CreateGitHubClient();
config ??= CreateConfig();
workspace ??= CreateWorkspace();
repo ??= CreateRepo(config);
llm ??= CreateLlmClient(config);

return new WorkContext(workItem, github, config, workspace, repo, llm);
}

public static WorkItem CreateWorkItem(
int number = 1,
string title = "Test Issue",
string body = "Test body",
string url = "https://github.com/test/repo/issues/1",
IReadOnlyList<string>? labels = null)
{
labels ??= new List<string> { "ready-for-agents" };
return new WorkItem(number, title, body, url, labels);
}

public static OctokitGitHubClient CreateGitHubClient()
{
var config = CreateConfig();
return new OctokitGitHubClient(config);
}

public static OrchestratorConfig CreateConfig(
string? workspacePath = null)
{
return new OrchestratorConfig(
OpenAiBaseUrl: "https://api.openai.com/v1",
OpenAiApiKey: "test-key",
OpenAiModel: "gpt-4o-mini",
DevModel: "gpt-4o",
TechLeadModel: "gpt-4o-mini",
WorkspacePath: workspacePath ?? "/tmp/test-workspace",
GitRemoteUrl: "https://github.com/test/repo.git",
GitAuthorName: "Test Agent",
GitAuthorEmail: "test@example.com",
GitHubToken: "test-token",
RepoOwner: "test-owner",
RepoName: "test-repo",
DefaultBaseBranch: "main",
PollIntervalSeconds: 120,
FastPollIntervalSeconds: 30,
WorkItemLabel: "ready-for-agents",
InProgressLabel: "in-progress",
DoneLabel: "done",
BlockedLabel: "blocked",
PlannerLabel: "agent:planner",
TechLeadLabel: "agent:techlead",
DevLabel: "agent:dev",
TestLabel: "agent:test",
ReleaseLabel: "agent:release",
UserReviewRequiredLabel: "user-review-required",
ReviewNeededLabel: "agent:review-needed",
ReviewedLabel: "agent:reviewed",
SpecQuestionsLabel: "spec-questions",
SpecClarifiedLabel: "spec-clarified",
CodeReviewNeededLabel: "code-review-needed",
CodeReviewApprovedLabel: "code-review-approved",
CodeReviewChangesRequestedLabel: "code-review-changes-requested",
ResetLabel: "agent:reset",
ProjectStatusInProgress: "In Progress",
ProjectStatusInReview: "In Review",
ProjectOwner: "test-owner",
ProjectOwnerType: "user",
ProjectNumber: 1,
ProjectStatusDone: "Done",
UseWorkflowMode: false
);
}

public static RepoWorkspace CreateWorkspace(string? path = null)
{
path ??= Path.Combine(Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}");
Directory.CreateDirectory(path);
return new RepoWorkspace(path);
}

public static RepoGit CreateRepo(OrchestratorConfig config)
{
return new RepoGit(config, config.WorkspacePath);
}

public static LlmClient CreateLlmClient(OrchestratorConfig config)
{
return new LlmClient(config);
}
}
45 changes: 45 additions & 0 deletions tests/TestHelpers/TempWorkspace.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Orchestrator.App;

namespace Orchestrator.App.Tests.TestHelpers;

internal sealed class TempWorkspace : IDisposable
{
private readonly string _path;
private readonly RepoWorkspace _workspace;

public TempWorkspace()
{
_path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}");
Directory.CreateDirectory(_path);
_workspace = new RepoWorkspace(_path);
}

public RepoWorkspace Workspace => _workspace;
public string WorkspacePath => _path;

public void CreateFile(string relativePath, string content)
{
var fullPath = System.IO.Path.Combine(_path, relativePath);
var directory = System.IO.Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(fullPath, content);
}

public void Dispose()
{
if (Directory.Exists(_path))
{
try
{
Directory.Delete(_path, recursive: true);
}
catch
{
// Best effort cleanup
}
}
}
}
117 changes: 117 additions & 0 deletions tests/Utilities/AgentHelpersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using Orchestrator.App.Agents;
using Orchestrator.App.Tests.TestHelpers;
using Xunit;

namespace Orchestrator.App.Tests.Utilities;

public class AgentHelpersTests
{
[Fact]
public void ValidateSpecFiles_WithValidPaths()
{
using var temp = new TempWorkspace();
var path = "orchestrator/src/Orchestrator.App/Valid.cs";
temp.CreateFile(path, "// ok");

var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace);

Assert.Empty(invalid);
}

[Fact]
public void ValidateSpecFiles_WithPathTraversalAttempts()
{
using var temp = new TempWorkspace();
var path = "orchestrator/src/../secrets.txt";

var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace);

Assert.Contains(path, invalid);
}

[Fact]
public void ValidateSpecFiles_WithDisallowedExtensions()
{
using var temp = new TempWorkspace();
var path = "orchestrator/src/Orchestrator.App/Bad.exe";

var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace);

Assert.Contains(path, invalid);
}

[Fact]
public void ValidateSpecFiles_WithNonExistentNewFiles()
{
using var temp = new TempWorkspace();
var path = "orchestrator/src/Orchestrator.App/NewFile.cs";

var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace);

Assert.Empty(invalid);
}

[Fact]
public void IsAllowedPath_WithVariousPrefixes()
{
Assert.True(AgentHelpers.IsAllowedPath("orchestrator/src/Orchestrator.App/Program.cs"));
Assert.True(AgentHelpers.IsAllowedPath("orchestrator/tests/Utilities/ExampleTests.cs"));
Assert.True(AgentHelpers.IsAllowedPath("Assets/Scripts/Gameplay.cs"));
Assert.True(AgentHelpers.IsAllowedPath("Assets/Tests/EditorTests.cs"));
Assert.True(AgentHelpers.IsAllowedPath("orchestrator/README.md"));
Assert.False(AgentHelpers.IsAllowedPath("orchestrator/docs/README.md"));
}

[Fact]
public void IsAllowedExtension_ForSupportedTypes()
{
Assert.True(AgentHelpers.IsAllowedExtension(".cs"));
Assert.True(AgentHelpers.IsAllowedExtension(".md"));
Assert.True(AgentHelpers.IsAllowedExtension(".json"));
Assert.True(AgentHelpers.IsAllowedExtension(".yml"));
Assert.True(AgentHelpers.IsAllowedExtension(".yaml"));
Assert.False(AgentHelpers.IsAllowedExtension(".txt"));
}

[Fact]
public void IsTestFile_Detection()
{
Assert.True(AgentHelpers.IsTestFile("src/Tests/Example.cs"));
Assert.True(AgentHelpers.IsTestFile("src\\Tests\\Example.cs"));
Assert.True(AgentHelpers.IsTestFile("ExampleTests.cs"));
Assert.False(AgentHelpers.IsTestFile("src/Example.cs"));
}

[Fact]
public void StripCodeFence_WithVariousFormats()
{
var csharp = "```csharp\nvar x = 1;\n```";
var json = "```json\n{ \"value\": 1 }\n```";
var noLang = "```\nplain\n```";

Assert.Equal("var x = 1;", AgentHelpers.StripCodeFence(csharp));
Assert.Equal("{ \"value\": 1 }", AgentHelpers.StripCodeFence(json));
Assert.Equal("plain", AgentHelpers.StripCodeFence(noLang));
}

[Fact]
public void StripCodeFence_WithNoFence()
{
Assert.Equal("plain", AgentHelpers.StripCodeFence("plain"));
}

[Fact]
public void StripCodeFence_WithIncompleteFence()
{
var content = "```\nstill open";

Assert.Equal("```\nstill open", AgentHelpers.StripCodeFence(content));
}

[Fact]
public void Truncate_WithVariousLengths()
{
Assert.Equal("short", AgentHelpers.Truncate("short", 10));
Assert.Equal("abcde\n...truncated...", AgentHelpers.Truncate("abcdef", 5));
}
}
Loading
Loading