Skip to content
Merged
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
169 changes: 148 additions & 21 deletions backend/tests/Taskdeck.Cli.Tests/CliTestHarnessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Taskdeck.Application.Services;
using Taskdeck.Infrastructure.Persistence;
using Xunit;

Expand Down Expand Up @@ -133,7 +134,8 @@ public async Task RunAsync_WhenCanceledAfterMigrationBegins_ReapsTheChildBeforeR
await WaitForStartupPhaseAsync(
harness.DataDirectory,
CliStartupTrace.MigrationBeginPhase,
TimeSpan.FromSeconds(10));
CliTestHarness.DefaultProcessTimeout,
runTask);

harness.LastStartedProcessId.Should().HaveValue();
ProcessHasExited(harness.LastStartedProcessId!.Value).Should().BeFalse();
Expand Down Expand Up @@ -169,6 +171,62 @@ await WaitForStartupPhaseAsync(
}
}

[Fact]
public async Task WaitForStartupPhase_WhenProcessCompletesEarly_ReportsBoundedRedactedDiagnostics()
{
var dataDirectory = Directory.CreateTempSubdirectory("taskdeck-cli-readiness-").FullName;
try
{
var completedProcess = Task.FromResult(new CliCommandResult(
ExitCode: 17,
StdOut: string.Empty,
StdErr: "authorization: Bearer TOP_SECRET_SENTINEL"));

Func<Task> action = async () => await WaitForStartupPhaseAsync(
dataDirectory,
CliStartupTrace.MigrationBeginPhase,
TimeSpan.FromSeconds(5),
completedProcess);

var failure = await action.Should().ThrowAsync<InvalidOperationException>();

failure.Which.Message.Should().Contain("exitCode=17")
.And.Contain("authorization: Bearer [redacted]")
.And.Contain("last=none")
.And.NotContain("TOP_SECRET_SENTINEL");
}
finally
{
Directory.Delete(dataDirectory, recursive: true);
}
}

[Fact]
public async Task WaitForStartupPhase_WhenReadinessIsMissing_UsesOneBoundedDeadline()
{
var dataDirectory = Directory.CreateTempSubdirectory("taskdeck-cli-readiness-").FullName;
var processCompletion = new TaskCompletionSource<CliCommandResult>(
TaskCreationOptions.RunContinuationsAsynchronously);
try
{
Func<Task> action = async () => await WaitForStartupPhaseAsync(
dataDirectory,
CliStartupTrace.MigrationBeginPhase,
TimeSpan.FromMilliseconds(50),
processCompletion.Task);

var failure = await action.Should().ThrowAsync<TimeoutException>();

failure.Which.Message.Should().Contain("within 0.05s")
.And.Contain("last=none");
}
finally
{
processCompletion.TrySetResult(new CliCommandResult(0, string.Empty, string.Empty));
Directory.Delete(dataDirectory, recursive: true);
}
}

[Theory]
[InlineData("api-key create --name TOP_SECRET_SENTINEL", "api-key/create")]
[InlineData("boards create TOP_SECRET_SENTINEL --json", "boards/create")]
Expand Down Expand Up @@ -1145,35 +1203,104 @@ private static string CreateSqliteConnectionString(string databasePath) =>
private static async Task WaitForStartupPhaseAsync(
string dataDirectory,
string expectedPhase,
TimeSpan timeout)
TimeSpan timeout,
Task<CliCommandResult> processTask)
{
using var timeoutCancellation = new CancellationTokenSource(timeout);
ArgumentNullException.ThrowIfNull(processTask);
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout));
}

using var observationCancellation = new CancellationTokenSource();
var phaseTask = ObserveStartupPhaseAsync(
dataDirectory,
expectedPhase,
observationCancellation.Token);
var deadlineTask = Task.Delay(timeout, observationCancellation.Token);
try
{
while (true)
var completedTask = await Task.WhenAny(phaseTask, processTask, deadlineTask);
if (completedTask == phaseTask)
{
foreach (var tracePath in Directory.EnumerateFiles(
dataDirectory,
"startup-*.trace",
SearchOption.TopDirectoryOnly))
{
var fileName = Path.GetFileNameWithoutExtension(tracePath);
var correlationId = fileName["startup-".Length..];
var snapshot = CliStartupTrace.ReadSnapshot(tracePath, correlationId);
if (string.Equals(snapshot.LastPhase, expectedPhase, StringComparison.Ordinal))
{
return;
}
}
await phaseTask;
return;
}

await Task.Delay(TimeSpan.FromMilliseconds(10), timeoutCancellation.Token);
if (completedTask == processTask)
{
var result = await processTask;
var snapshot = ReadStartupTraceSnapshot(dataDirectory);
var standardError = FormatDiagnosticStandardError(result.StdErr);
throw new InvalidOperationException(
$"CLI process exited before readiness could confirm {expectedPhase} while the " +
"owned child was live; " +
$"exitCode={result.ExitCode}; stderr={standardError}; " +
snapshot.ToDiagnosticString());
}

throw new TimeoutException(
$"CLI startup trace did not reach the expected phase within {timeout.TotalSeconds:0.###}s; " +
$"{ReadStartupTraceSnapshot(dataDirectory).ToDiagnosticString()}");
}
catch (OperationCanceledException) when (timeoutCancellation.IsCancellationRequested)
finally
{
throw new TimeoutException(
$"CLI startup trace did not reach the expected phase within {timeout.TotalSeconds}s.");
observationCancellation.Cancel();
try
{
await phaseTask;
}
catch (OperationCanceledException) when (observationCancellation.IsCancellationRequested)
{
// The readiness observer is owned by this bounded probe.
}
}
}

private static async Task ObserveStartupPhaseAsync(
string dataDirectory,
string expectedPhase,
CancellationToken cancellationToken)
{
while (true)
{
var snapshot = ReadStartupTraceSnapshot(dataDirectory);
if (string.Equals(snapshot.LastPhase, expectedPhase, StringComparison.Ordinal))
{
return;
}

await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken);
}
}

private static CliStartupTraceSnapshot ReadStartupTraceSnapshot(string dataDirectory)
{
foreach (var tracePath in Directory.EnumerateFiles(
dataDirectory,
"startup-*.trace",
SearchOption.TopDirectoryOnly))
{
var fileName = Path.GetFileNameWithoutExtension(tracePath);
var correlationId = fileName["startup-".Length..];
return CliStartupTrace.ReadSnapshot(tracePath, correlationId);
}

return CliStartupTraceSnapshot.Unavailable;
}

private static string FormatDiagnosticStandardError(string standardError)
{
var redacted = SensitiveDataRedactor.Redact(standardError).ReplaceLineEndings("\\n");
if (string.IsNullOrWhiteSpace(redacted))
{
return "none";
}

const int maximumDiagnosticLength = 512;
return redacted.Length <= maximumDiagnosticLength
? redacted
: $"{redacted[..maximumDiagnosticLength]}...";
}

private static async Task ReapThenThrowAsync(Process process, Exception failure)
Expand Down
Loading