Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
mika kuns f21c65be18
All checks were successful
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
feat(ui): richer diff viewer + surface child roadblocks on parents
- UnifiedDiffParser detects added/deleted/renamed/binary files; diff
  modal shows a file list, binary/empty placeholders, and can diff a
  merged task by commit range after its worktree is gone
- DetailsIslandViewModel flags children needing attention (failed,
  cancelled, awaiting review, or with roadblocks) on the parent
- GitService gains worktree head-commit/range support; planning chain,
  merge orchestration, and session manager tweaks with updated tests
- refresh app/installer/worker icons

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:40:59 +02:00

194 lines
8.3 KiB
C#

using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern.
file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; }
}
file sealed class E2ENullHubClients : IHubClients
{
public IClientProxy All => E2ENullClientProxy.Instance;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
public IClientProxy Users(IReadOnlyList<string> userIds) => E2ENullClientProxy.Instance;
}
file sealed class E2ENullClientProxy : IClientProxy
{
public static readonly E2ENullClientProxy Instance = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
file sealed class E2EFakeHubContext : IHubContext<WorkerHub>
{
public IHubClients Clients { get; } = new E2ENullHubClients();
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class PlanningEndToEndTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly AppSettingsRepository _settingsRepo;
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _root;
private readonly TaskStateServiceBuilder.Built _built;
private readonly PlanningSessionManager _manager;
private readonly DefaultHttpContext _httpContext;
private readonly PlanningMcpContextAccessor _accessor;
private readonly PlanningMcpService _svc;
public PlanningEndToEndTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
_git = new GitService();
_cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_root, "central") };
_settingsRepo = new AppSettingsRepository(_ctx);
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
_manager = new PlanningSessionManager(
_tasks, _lists, _settingsRepo, _git, _cfg, _root, _built.State, _built.Chain);
_httpContext = new DefaultHttpContext();
_accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext });
var broadcaster = new HubBroadcaster(new E2EFakeHubContext());
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster, _built.State, _built.Chain);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
[Fact]
public async Task StartThenCreateThenFinalize_FullFlow()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Big Task",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(parent);
var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(startCtx.WorktreePath, ".mcp.json")));
// Wire the ambient context so _svc reads the correct parent
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _svc.CreateChildTask("sub 1", null, null, CancellationToken.None);
await _svc.CreateChildTask("sub 2", null, null, CancellationToken.None);
var count = await _svc.Finalize(true, CancellationToken.None);
Assert.Equal(2, count);
var reload = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
var kids = await _tasks.GetChildrenAsync(parent.Id);
// Finalize no longer auto-queues. Children stay Idle and chain-linked
// (head unblocked, rest BlockedBy their predecessor) until the user
// explicitly queues the plan.
Assert.Equal(TaskStatus.Idle, kids[0].Status);
Assert.Null(kids[0].BlockedByTaskId);
Assert.Equal(TaskStatus.Idle, kids[1].Status);
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
}
// Regression: original bug was "queue never picks up planning tasks". Finalize
// leaves children Idle; queueing the plan (the explicit user gate) must make the
// first child claimable by the picker automatically — without a manual WakeQueue().
[Fact]
public async Task QueuePlanAfterFinalize_FirstChildIsClaimedByPicker_WithinDeadline()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Parent",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(parent);
await _manager.StartAsync(parent.Id, CancellationToken.None);
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _svc.CreateChildTask("c1", null, null, CancellationToken.None);
await _svc.CreateChildTask("c2", null, null, CancellationToken.None);
await _svc.CreateChildTask("c3", null, null, CancellationToken.None);
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
var firstChildId = kidsBefore[0].Id;
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
// Finalize leaves every child Idle — nothing is claimable yet.
var afterFinalize = await _tasks.GetChildrenAsync(parent.Id);
Assert.All(afterFinalize, k => Assert.Equal(TaskStatus.Idle, k.Status));
// Queueing the plan is the gate that enqueues + auto-wakes. The picker should
// then pick the first child immediately, without a manual WakeQueue().
var wakesBefore = _built.WakeCount();
await _built.Chain.QueuePlanAsync(parent.Id, CancellationToken.None);
var picker = new QueuePicker(_db.CreateFactory());
TaskEntity? claimed = null;
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < 200)
{
claimed = await picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
if (claimed is not null) break;
await Task.Delay(10);
}
Assert.NotNull(claimed);
Assert.Equal(firstChildId, claimed!.Id);
Assert.Equal(TaskStatus.Running, claimed.Status);
Assert.True(_built.WakeCount() > wakesBefore,
"QueuePlanAsync → EnqueueAsync should auto-wake the queue.");
}
}