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
34 changes: 34 additions & 0 deletions src/Netclaw.Cli.Tests/Cli/CliArgsParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,40 @@ private static IReadOnlySet<string> ExtractHelpListedCommands(string programSour
return commands;
}

/// <summary>
/// Regression coverage for the canary "help executes instead of printing help" family of
/// bugs (<c>netclaw memory backfill-embeddings --help</c> ran a real embed pass;
/// <c>netclaw daemon stop --help</c> would have actually stopped the daemon). Every fix
/// site (MemoryCommand, the Program.cs daemon dispatch, WebhooksCommand, ReminderCommand)
/// routes through this one helper, so its own scan logic only needs proving once.
/// </summary>
[Theory]
[InlineData(new[] { "memory", "backfill-embeddings" }, false)]
[InlineData(new[] { "memory", "backfill-embeddings", "--force" }, false)]
[InlineData(new[] { "memory", "backfill-embeddings", "--help" }, true)]
[InlineData(new[] { "memory", "backfill-embeddings", "-h" }, true)]
[InlineData(new[] { "memory", "backfill-embeddings", "help" }, true)]
[InlineData(new[] { "daemon", "stop" }, false)]
[InlineData(new[] { "daemon", "stop", "--help" }, true)]
public void HasTrailingHelpToken_scans_from_startIndex(string[] args, bool expected)
{
Assert.Equal(expected, CliArgsParser.HasTrailingHelpToken(args, startIndex: 2));
}

[Fact]
public void HasTrailingHelpToken_ignores_tokens_before_startIndex()
{
// The subcommand itself ("help") sits at index 1, before startIndex — this helper is
// only meant to scan trailing args, so it must not double-count the subcommand slot.
Assert.False(CliArgsParser.HasTrailingHelpToken(["memory", "help"], startIndex: 2));
}

[Fact]
public void HasTrailingHelpToken_returns_false_for_empty_tail()
{
Assert.False(CliArgsParser.HasTrailingHelpToken(["memory", "backfill-embeddings"], startIndex: 2));
}

private static string ReadProgramCsSource() => File.ReadAllText(Path.Combine(FindRepoRoot(), "src", "Netclaw.Cli", "Program.cs"));

private static string FindRepoRoot()
Expand Down
53 changes: 53 additions & 0 deletions src/Netclaw.Cli.Tests/Cli/DaemonCommandDispatchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// -----------------------------------------------------------------------
// <copyright file="DaemonCommandDispatchTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Cli.Daemon;
using Xunit;

namespace Netclaw.Cli.Tests.Cli;

/// <summary>
/// Regression coverage for the canary finding that <c>netclaw daemon stop --help</c> (and
/// start/status/install/uninstall) executed the real lifecycle action instead of printing
/// help, because Program.cs's daemon dispatch only checked the subcommand slot (args[1]) for
/// a help token. Program.cs is top-level statements, so the decision is extracted into
/// <see cref="DaemonCommandDispatch"/> to make it independently unit-testable — mirroring
/// <c>DaemonCliArgs</c>'s <c>netclawd --version</c> extraction for the same reason.
/// </summary>
public sealed class DaemonCommandDispatchTests
{
[Theory]
[InlineData("start")]
[InlineData("stop")]
[InlineData("status")]
[InlineData("install")]
[InlineData("uninstall")]
public void ShouldShowHelpInsteadOfExecuting_true_for_lifecycle_verb_with_trailing_help(string verb)
{
Assert.True(DaemonCommandDispatch.ShouldShowHelpInsteadOfExecuting(verb, ["daemon", verb, "--help"]));
}

[Theory]
[InlineData("start")]
[InlineData("stop")]
[InlineData("status")]
[InlineData("install")]
[InlineData("uninstall")]
public void ShouldShowHelpInsteadOfExecuting_false_for_lifecycle_verb_without_help(string verb)
{
Assert.False(DaemonCommandDispatch.ShouldShowHelpInsteadOfExecuting(verb, ["daemon", verb]));
}

[Theory]
[InlineData("pair")]
[InlineData("devices")]
[InlineData("help")]
public void ShouldShowHelpInsteadOfExecuting_false_for_verbs_with_their_own_help_handling(string verb)
{
// `pair`/`devices` guard their own trailing --help inline in Program.cs, and "help"
// itself is normalized away before this check runs — none should be double-guarded here.
Assert.False(DaemonCommandDispatch.ShouldShowHelpInsteadOfExecuting(verb, ["daemon", verb, "--help"]));
}
}
109 changes: 109 additions & 0 deletions src/Netclaw.Cli.Tests/Cli/DaemonManagerGracefulShutdownTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// -----------------------------------------------------------------------
// <copyright file="DaemonManagerGracefulShutdownTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Time.Testing;
using Netclaw.Cli.Daemon;
using Netclaw.Configuration;
using Netclaw.Tests.Utilities;
using Xunit;

namespace Netclaw.Cli.Tests.Cli;

/// <summary>
/// Covers the canary daemon-stop finding: <c>systemctl --user stop netclaw.service</c> landed
/// in <c>failed (Result: signal)</c> because <see cref="DaemonManager.StopAsync"/>'s SIGTERM
/// grace period (previously a hardcoded 10s) was far shorter than the ~200s the daemon's own
/// Akka CoordinatedShutdown session-drain phase is deliberately allotted — so the CLI itself
/// gave up and force-killed the daemon long before a legitimately slow (in-flight LLM call)
/// graceful shutdown could finish. It still died, so `netclaw daemon stop` (ExecStop) reported
/// success, but via SIGKILL rather than a clean exit — exactly what systemd's
/// <c>failed (Result: signal)</c> was observing.
///
/// These tests exercise the two testable halves of the fix: (1) the internal
/// <see cref="DaemonManager.WaitForExitAsync"/> poll now honors an injected
/// <see cref="TimeProvider"/> end-to-end (not just for its deadline math), so the up-to-200s
/// wait can be driven with a <see cref="FakeTimeProvider"/> instead of a real sleep; and
/// (2) the generated systemd unit's <c>TimeoutStopSec=</c> stays in lockstep with
/// <see cref="DaemonConfig.GracefulShutdownBudget"/> so systemd itself never SIGKILLs the whole
/// cgroup out from under a still-legitimately-waiting <c>ExecStop=</c>.
/// </summary>
public sealed class DaemonManagerGracefulShutdownTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
private readonly NetclawPaths _paths;

public DaemonManagerGracefulShutdownTests()
{
_paths = new NetclawPaths(_dir.Path);
_paths.EnsureDirectoriesExist();
}

public void Dispose() => _dir.Dispose();

[Fact]
public async Task WaitForExitAsync_ReturnsTrue_Immediately_WhenProcessAlreadyExited()
{
var manager = new DaemonManager(_paths, TimeProvider.System);
using var exited = StartAndWaitForRealExit();

var result = await manager.WaitForExitAsync(exited, TimeSpan.FromSeconds(200), CancellationToken.None);

Assert.True(result);
}

[Fact]
public async Task WaitForExitAsync_ReturnsFalse_OnceVirtualClockPassesTimeout_WithoutRealTimeDelay()
{
var fakeTime = new FakeTimeProvider();
var manager = new DaemonManager(_paths, fakeTime);
// The current test process never exits mid-test — stands in for a daemon still
// draining a stuck/slow session.
var neverExits = Process.GetCurrentProcess();

var waitTask = manager.WaitForExitAsync(neverExits, DaemonConfig.GracefulShutdownBudget, CancellationToken.None);

// A single jump past the full budget — if the poll delay inside WaitForExitAsync were
// still a bare real-time `Task.Delay(200)` (the pre-fix shape), this test would need to
// actually wait out that real time instead of resolving from one Advance() call.
fakeTime.Advance(DaemonConfig.GracefulShutdownBudget + TimeSpan.FromSeconds(1));

var result = await waitTask;

Assert.False(result);
}

[Fact]
public void BuildDaemonUnitContent_SetsTimeoutStopSec_ConsistentWithGracefulShutdownBudget()
{
var unit = DaemonManager.BuildDaemonUnitContent(
"/opt/netclaw/netclawd", "/opt/netclaw/netclaw", "/opt/netclaw/daemon.env");

var expectedTimeoutStopSec = (int)(DaemonConfig.GracefulShutdownBudget + TimeSpan.FromSeconds(30)).TotalSeconds;

Assert.Contains($"TimeoutStopSec={expectedTimeoutStopSec}", unit, StringComparison.Ordinal);

// TimeoutStopSec bounds the ENTIRE stop job (ExecStop's own runtime included), so it
// must leave systemd comfortably behind netclaw daemon stop's own SIGTERM-wait ceiling
// — otherwise systemd would SIGKILL the cgroup mid-ExecStop before the CLI's own,
// more-informative timeout/escalation logic ever gets to run.
Assert.True(
expectedTimeoutStopSec > DaemonConfig.GracefulShutdownBudget.TotalSeconds,
"Unit TimeoutStopSec must exceed DaemonManager.StopAsync's own SIGTERM wait.");
}

private static Process StartAndWaitForRealExit()
{
var psi = OperatingSystem.IsWindows()
? new ProcessStartInfo("cmd.exe", "/c exit 0")
: new ProcessStartInfo("/bin/sh", "-c \"exit 0\"");
psi.UseShellExecute = false;
psi.CreateNoWindow = true;

var process = Process.Start(psi)!;
process.WaitForExit();
return process;
}
}
41 changes: 41 additions & 0 deletions src/Netclaw.Cli.Tests/Memory/MemoryCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,47 @@ public async Task BackfillEmbeddings_fails_clearly_when_autodownload_is_false_an
Assert.Contains("AutoDownload", stderr);
}

[Theory]
[InlineData("--help")]
[InlineData("-h")]
[InlineData("help")]
public async Task BackfillEmbeddings_help_flag_prints_help_and_does_not_execute(string helpToken)
{
// Canary regression: `netclaw memory backfill-embeddings --help` was executing the real
// provision-and-embed run (downloading models, writing embeddings) instead of printing
// help, because only args[1] (the subcommand slot) was checked for a help token. Prove
// the fix by seeding a document that WOULD be embedded if the command ran for real (as
// in BackfillEmbeddings_embeds_missing_documents_and_reports_a_summary above) and
// asserting nothing was written.
var paths = CreateTempPaths(prePlaceValidModel: true);
var config = BuildConfig(autoDownload: true);

var store = new SQLiteMemoryStore(paths.MemorySqliteDbPath, TimeProvider.System);
await store.InitializeAsync(TestContext.Current.CancellationToken);
await SeedDocumentAsync(store, "doc-1", "Doc One", "first body");

var (exitCode, stdout) = await RunCapturedAsync(["memory", "backfill-embeddings", helpToken], paths, config);

Assert.Equal(0, exitCode);
Assert.Contains("Usage: netclaw memory <subcommand>", stdout);
Assert.DoesNotContain("Embedding", stdout);

var rows = await store.GetEmbeddingsForModelAsync(ModelId, TestContext.Current.CancellationToken);
Assert.Empty(rows);
}

[Fact]
public async Task TopLevelHelp_still_prints_help()
{
var paths = CreateTempPaths(prePlaceValidModel: false);
var config = BuildConfig(autoDownload: false);

var (exitCode, stdout) = await RunCapturedAsync(["memory", "--help"], paths, config);

Assert.Equal(0, exitCode);
Assert.Contains("Usage: netclaw memory <subcommand>", stdout);
}

[Fact]
public async Task BackfillEmbeddings_with_force_re_embeds_every_recallable_document()
{
Expand Down
91 changes: 91 additions & 0 deletions src/Netclaw.Cli.Tests/Reminder/ReminderCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// -----------------------------------------------------------------------
// <copyright file="ReminderCommandTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Cli.Reminder;
using Xunit;

namespace Netclaw.Cli.Tests.Reminder;

/// <summary>
/// Covers the missed-help pattern audited alongside the canary-reported
/// <c>netclaw memory backfill-embeddings --help</c> bug: none of
/// <see cref="ReminderCommand"/>'s subcommand handlers had their own <c>--help</c>
/// check, so a trailing help token was silently ignored and the subcommand ran for
/// real. <c>list</c> is the sharpest example — it takes no positional arguments at
/// all, so `reminder list --help` used to reach the live daemon instead of printing
/// help. All tests pass <c>daemonApi: null</c> to prove the help check short-circuits
/// before the "requires a running daemon" branch is ever reached.
/// </summary>
public sealed class ReminderCommandTests
{
[Theory]
[InlineData("--help")]
[InlineData("-h")]
[InlineData("help")]
public async Task List_TrailingHelpFlag_PrintsHelp_WithoutRequiringDaemon(string helpToken)
{
var (exitCode, stdout) = await RunCapturedAsync(["reminder", "list", helpToken]);

Assert.Equal(0, exitCode);
Assert.Contains("Usage: netclaw reminder <subcommand>", stdout);
Assert.DoesNotContain("requires a running daemon", stdout);
}

[Fact]
public async Task List_WithoutHelpFlag_StillRequiresDaemon()
{
// Regression guard: the new trailing-help scan must not swallow ordinary
// subcommand invocations that legitimately need the daemon.
var (exitCode, _, stderr) = await RunCapturedWithStderrAsync(["reminder", "list"]);

Assert.Equal(1, exitCode);
Assert.Contains("requires a running daemon", stderr);
}

[Fact]
public async Task Create_TrailingHelpFlag_AfterFullArgs_PrintsHelp_WithoutRequiringDaemon()
{
var (exitCode, stdout) = await RunCapturedAsync(
["reminder", "create", "id", "once", "30m", "do it", "--help"]);

Assert.Equal(0, exitCode);
Assert.Contains("Usage: netclaw reminder <subcommand>", stdout);
}

[Fact]
public async Task TopLevelHelp_StillPrintsHelp()
{
var (exitCode, stdout) = await RunCapturedAsync(["reminder", "--help"]);

Assert.Equal(0, exitCode);
Assert.Contains("Usage: netclaw reminder <subcommand>", stdout);
}

private static async Task<(int ExitCode, string Stdout)> RunCapturedAsync(string[] args)
{
var (exitCode, stdout, _) = await RunCapturedWithStderrAsync(args);
return (exitCode, stdout);
}

private static async Task<(int ExitCode, string Stdout, string Stderr)> RunCapturedWithStderrAsync(string[] args)
{
var originalOut = Console.Out;
var originalError = Console.Error;
using var stdout = new StringWriter();
using var stderr = new StringWriter();
Console.SetOut(stdout);
Console.SetError(stderr);
try
{
var exitCode = await ReminderCommand.RunAsync(args, daemonApi: null);
return (exitCode, stdout.ToString(), stderr.ToString());
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
}
30 changes: 30 additions & 0 deletions src/Netclaw.Cli.Tests/Webhooks/WebhooksCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,36 @@ public async Task HelpFlag_ReturnsZero()
Assert.Equal(0, result);
}

[Theory]
[InlineData("--help")]
[InlineData("-h")]
public async Task List_TrailingHelpFlag_PrintsHelp_AndDoesNotList(string helpToken)
{
// A configured route WOULD show up in `webhooks list`'s output if the command ran for
// real, so its absence from stdout proves the help check pre-empted execution rather
// than just happening to print a route table that also mentions "Usage".
CreateValidRoute("test-route");

using var stdout = new StringWriter();
var result = await WebhooksCommand.RunAsync(["webhooks", "list", helpToken], _paths, stdout);

Assert.Equal(0, result);
Assert.Contains("Usage: netclaw webhooks <subcommand>", stdout.ToString());
Assert.DoesNotContain("test-route", stdout.ToString());
}

[Fact]
public async Task Set_TrailingHelpFlag_PrintsMoreSpecificSetHelp_NotGenericHelp()
{
// `set` has its own more specific WriteSetHelp() and must not be shadowed by the
// generic trailing-help check added for list/show/delete/validate.
using var stdout = new StringWriter();
var result = await WebhooksCommand.RunAsync(["webhooks", "set", "test-route", "--help"], _paths, stdout);

Assert.Equal(0, result);
Assert.Contains("Usage: netclaw webhooks set <route> [options]", stdout.ToString());
}

private void CreateValidRoute(string routeName, string secret = "test-secret", string prompt = "Test prompt")
{
var route = new WebhookRouteConfig
Expand Down
Loading
Loading