fix(data): normalize a list working dir's trailing separator on write
A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer that puts it on a Windows command line: argv rules read \" as an escaped quote, so the token never closes. "Open in terminal" passed wt.exe a starting directory of C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had already corrupted the ConPTY list handler's arg list in August. Paths.TrimTrailingSeparator is now the single helper (replacing the copies in InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it on Add/Update, which covers every writer: UI create, repo import, hub UpdateList, and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its quoting is correct regardless of what's stored.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
// A list working dir stored with a trailing separator ("C:\repo\") breaks every consumer that
|
||||
// puts it on a Windows command line: the token's closing quote is escaped by the backslash, so
|
||||
// "C:\repo\" parses as C:\repo" and the arg (or every arg after it) is corrupted. Observed twice
|
||||
// -- ConPTY list handlers (2026-08-06) and "Open in terminal" (2026-08-10). Normalizing on write
|
||||
// is the only place that covers all writers (UI, repo import, hub, MCP).
|
||||
public sealed class WorkingDirNormalizationTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
|
||||
public WorkingDirNormalizationTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_workdir_{Guid.NewGuid():N}.db");
|
||||
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
_ctx = new ClaudeDoDbContext(options);
|
||||
_ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\Dev\Tests\StaplerTracking\", @"C:\Dev\Tests\StaplerTracking")]
|
||||
[InlineData(@"C:\Dev\Tests\StaplerTracking\\", @"C:\Dev\Tests\StaplerTracking")]
|
||||
[InlineData("C:/Dev/Tests/StaplerTracking/", "C:/Dev/Tests/StaplerTracking")]
|
||||
[InlineData(@"C:\Dev\Tests\StaplerTracking", @"C:\Dev\Tests\StaplerTracking")]
|
||||
public void TrimTrailingSeparator_strips_trailing_separators(string input, string expected) =>
|
||||
Assert.Equal(expected, Paths.TrimTrailingSeparator(input));
|
||||
|
||||
// A bare drive root IS its trailing separator -- "C:" without it means "current dir on C:",
|
||||
// a different location. Same for the unix root.
|
||||
[Theory]
|
||||
[InlineData(@"C:\")]
|
||||
[InlineData("/")]
|
||||
public void TrimTrailingSeparator_leaves_a_bare_root_alone(string root) =>
|
||||
Assert.Equal(root, Paths.TrimTrailingSeparator(root));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TrimTrailingSeparator_passes_blank_through(string? input) =>
|
||||
Assert.Equal(input, Paths.TrimTrailingSeparator(input));
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_normalizes_a_trailing_separator()
|
||||
{
|
||||
var repo = new ListRepository(_ctx);
|
||||
await repo.AddAsync(new ListEntity
|
||||
{
|
||||
Id = "l1",
|
||||
Name = "Stapler Tracking",
|
||||
WorkingDir = @"C:\Dev\Tests\StaplerTracking\",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
var stored = await repo.GetByIdAsync("l1");
|
||||
Assert.Equal(@"C:\Dev\Tests\StaplerTracking", stored!.WorkingDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_normalizes_a_trailing_separator()
|
||||
{
|
||||
var repo = new ListRepository(_ctx);
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var entity = await repo.GetByIdAsync("l1");
|
||||
entity!.WorkingDir = @"C:\Dev\Repos\Bandel.Hub\";
|
||||
await repo.UpdateAsync(entity);
|
||||
|
||||
var stored = await repo.GetByIdAsync("l1");
|
||||
Assert.Equal(@"C:\Dev\Repos\Bandel.Hub", stored!.WorkingDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_leaves_a_null_working_dir_null()
|
||||
{
|
||||
var repo = new ListRepository(_ctx);
|
||||
await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
|
||||
var stored = await repo.GetByIdAsync("l1");
|
||||
Assert.Null(stored!.WorkingDir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user