Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
137 changes: 137 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ModelManagerPageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// -----------------------------------------------------------------------
// <copyright file="ModelManagerPageTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Netclaw.Cli.Provider;
using Netclaw.Cli.Tui;
using Netclaw.Configuration;
using Netclaw.Providers;
using Netclaw.Tests.Utilities;
using Termina;
using Termina.Hosting;
using Termina.Input;
using Termina.Terminal;
using Xunit;

namespace Netclaw.Cli.Tests.Tui;

public sealed class ModelManagerPageTests : IDisposable
{
private readonly DisposableTempDir _dir = new();
private readonly NetclawPaths _paths;
private readonly FakeProviderProbe _fakeProbe = new();
private readonly ProviderDescriptorRegistry _registry = ProviderCommand.CreateDefaultRegistry();

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

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

[Fact]
public async Task InitialRoleOverview_ShowsConfiguredMainAndFallbackModels()
{
WriteConfig(new Dictionary<string, object>
{
["configVersion"] = 1,
["Providers"] = new Dictionary<string, object>
{
["deepseek-test"] = new Dictionary<string, object>
{
["Type"] = "openai-compatible",
["Endpoint"] = "https://api.deepseek.example"
},
["big-gpu"] = new Dictionary<string, object>
{
["Type"] = "openai-compatible",
["Endpoint"] = "http://gpu.example"
}
},
["Models"] = new Dictionary<string, object>
{
["Definitions"] = new Dictionary<string, object>
{
["deepseek-v4-flash"] = new Dictionary<string, object>
{
["Provider"] = "deepseek-test",
["ModelId"] = "deepseek-v4-flash"
},
["fallback-model"] = new Dictionary<string, object>
{
["Provider"] = "big-gpu",
["ModelId"] = "fallback-model"
}
},
["Roles"] = new Dictionary<string, object>
{
["Main"] = "deepseek-v4-flash",
["Fallback"] = "fallback-model"
}
}
});

var (terminal, app) = CreateHeadlessApp(out var input);

using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var run = app.RunAsync(appCts.Token);

try
{
using var overviewCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token);
overviewCts.CancelAfter(TimeSpan.FromSeconds(2));
await WaitForConditionAsync(
() => terminal.Contains("deepseek-test")
&& terminal.Contains("deepseek-v4-flash")
&& terminal.Contains("big-gpu")
&& terminal.Contains("fallback-model"),
overviewCts.Token);
}
finally
{
input.EnqueueKey(ConsoleKey.Q, control: true);
await run.WaitAsync(appCts.Token);
}
}

private (VirtualTerminal Terminal, TerminaApplication App) CreateHeadlessApp(
out VirtualInputSource input)
{
var terminal = new VirtualTerminal(160, 40);
var virtualInput = new VirtualInputSource();
input = virtualInput;

var services = new ServiceCollection();
services.AddSingleton<IAnsiTerminal>(terminal);
services.AddTerminaVirtualInput(virtualInput);
services.AddTermina("/model", builder =>
{
builder.RegisterRoute<ModelManagerPage, ModelManagerViewModel>(
"/model",
_ => new ModelManagerPage(),
_ => new ModelManagerViewModel(_paths, _fakeProbe, _registry));
});

var serviceProvider = services.BuildServiceProvider();
return (terminal, serviceProvider.GetRequiredService<TerminaApplication>());
}

private void WriteConfig(Dictionary<string, object> data)
{
File.WriteAllText(_paths.NetclawConfigPath,
JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
}

private static async Task WaitForConditionAsync(Func<bool> predicate, CancellationToken ct)
{
while (!predicate())
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
}
}
}
4 changes: 2 additions & 2 deletions src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ public ModelManagerViewModelTests()
public void Dispose() => _dir.Dispose();

[Fact]
public void StartsAtRoleOverview()
public void StartsInLoadingState()
{
using var vm = CreateViewModel();
Assert.Equal(ModelManagerState.RoleOverview, vm.CurrentState.Value);
Assert.Equal(ModelManagerState.Loading, vm.CurrentState.Value);
}

[Fact]
Expand Down
51 changes: 51 additions & 0 deletions src/Netclaw.Cli.Tests/Tui/ProviderManagerPageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Netclaw.Cli.Tui;
using Netclaw.Configuration;
using Netclaw.Providers;
using Netclaw.Providers.OAuth;
using Netclaw.Tests.Utilities;
using Termina;
using Termina.Hosting;
Expand Down Expand Up @@ -102,6 +103,47 @@ public async Task GitHubCopilotEnterpriseInputs_AcceptTypedHostAndApiBase()
Assert.Equal("https://api.ghe.example.com", vm.NewVendorOptions["GitHubApiBase"]);
}

[Fact]
public async Task OAuthDeviceFlow_WhenAuthorizationStarts_ShowsTheUserCode()
{
var (terminal, app, vm) = CreateHeadlessApp(out var input);

using var appCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var run = app.RunAsync(appCts.Token);

try
{
await WaitForConditionAsync(() => terminal.Contains("Provider Manager"), appCts.Token);

vm.NewProviderType = "openai";
vm.CurrentState.Value = ProviderManagerState.AddOAuthDeviceFlow;
vm.StateVersion.Value++;
vm.RequestRedraw();

await WaitForConditionAsync(
() => terminal.Contains("Starting device authorization..."),
appCts.Token);

vm.OAuth.UserCode = "ABCD-EFGH";
vm.OAuth.VerificationUri = "https://auth.openai.com/device";
vm.OAuth.FlowState.Value = DeviceFlowState.WaitingForUser;
vm.StateVersion.Value++;
vm.RequestRedraw();

using var transitionCts = CancellationTokenSource.CreateLinkedTokenSource(appCts.Token);
transitionCts.CancelAfter(TimeSpan.FromSeconds(2));
await WaitForConditionAsync(
() => terminal.Contains("Enter code: ABCD-EFGH")
&& !terminal.Contains("Starting device authorization..."),
transitionCts.Token);
}
finally
{
input.EnqueueKey(ConsoleKey.Q, control: true);
await run.WaitAsync(appCts.Token);
}
}

private (VirtualTerminal Terminal, TerminaApplication App, ProviderManagerViewModel Vm)
CreateHeadlessApp(out VirtualInputSource input)
{
Expand Down Expand Up @@ -178,4 +220,13 @@ private void WriteConfig(Dictionary<string, object> data)
File.WriteAllText(_paths.NetclawConfigPath,
JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
}

private static async Task WaitForConditionAsync(Func<bool> predicate, CancellationToken ct)
{
while (!predicate())
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
}
}
}
9 changes: 8 additions & 1 deletion src/Netclaw.Cli/Tui/ModelManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private LayoutNode BuildContent()

return ViewModel.CurrentState.Value switch
{
ModelManagerState.Loading => BuildLoading(),
ModelManagerState.RoleOverview => BuildRoleOverview(),
ModelManagerState.SelectProvider => BuildProviderSelection(),
ModelManagerState.DiscoverModels => BuildDiscoverModels(),
Expand Down Expand Up @@ -135,9 +136,14 @@ private LayoutNode BuildKeyBindings()
// Content views
// ═══════════════════════════════════════════════════════════════════

private static ILayoutNode BuildLoading()
{
return new TextNode(" Loading model configuration...")
.WithForeground(Color.BrightBlack);
}

private ILayoutNode BuildRoleOverview()
{
var models = ViewModel.Models;
var items = new[] { "Main", "Fallback", "Compaction" };

_roleList = Layouts.SelectionList(items, role => FormatRoleItem(role, role switch
Expand Down Expand Up @@ -430,6 +436,7 @@ private enum ModelManagerContent
RoleOverview = ModelManagerState.RoleOverview,
SelectProvider = ModelManagerState.SelectProvider,
ConfirmAssignment = ModelManagerState.ConfirmAssignment,
Loading = ModelManagerState.Loading,
DiscoverProbing,
DiscoverFailed,
DiscoverEmpty,
Expand Down
7 changes: 5 additions & 2 deletions src/Netclaw.Cli/Tui/ModelManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public enum ModelManagerState
RoleOverview,
SelectProvider,
DiscoverModels,
ConfirmAssignment
ConfirmAssignment,
Loading
}

/// <summary>
Expand All @@ -47,7 +48,7 @@ public sealed class ModelManagerViewModel : ReactiveViewModel
/// </summary>
internal bool IsEmbeddedInConfig { get; set; }

public ReactiveProperty<ModelManagerState> CurrentState { get; } = new(ModelManagerState.RoleOverview);
public ReactiveProperty<ModelManagerState> CurrentState { get; } = new(ModelManagerState.Loading);
public ReactiveProperty<string> StatusMessage { get; } = new("");
public ReactiveProperty<bool> IsProbing { get; } = new(false);
public ReactiveProperty<ProviderProbeResult?> ProbeResult { get; } = new(null);
Expand Down Expand Up @@ -87,6 +88,8 @@ public override void OnActivated()
{
base.OnActivated();
Refresh();
CurrentState.Value = ModelManagerState.RoleOverview;
NotifyStateChanged();

Input.OfType<IInputEvent, KeyPressed>()
.Subscribe(HandleGlobalKey)
Expand Down
1 change: 1 addition & 0 deletions src/Netclaw.Cli/Tui/ProviderManagerPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ private LayoutNode BuildContent()
var state = ViewModel.CurrentState.Value;
var revision = state is ProviderManagerState.Loading
or ProviderManagerState.AddValidating
or ProviderManagerState.AddOAuthDeviceFlow
or ProviderManagerState.Details
? ViewModel.StateVersion.Value
: 0;
Expand Down
7 changes: 3 additions & 4 deletions tests/smoke/assertions/model-manager.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
#!/usr/bin/env bash
# model-manager.tape post-tape assertion.
#
# The tape's Wait+Screen anchors on "Model Manager" and TAPE$ are
# the primary regression detectors — a rendering failure or crash exits
# vhs non-zero. This script intentionally does nothing further.
# The tape anchors on both configured model IDs. These anchors prove that
# the role overview reads configuration after view-model activation.

set -euo pipefail
echo "model-manager: no post-tape assertion (vhs exit code is the test)"
echo "model-manager: configured main and fallback roles rendered"
24 changes: 18 additions & 6 deletions tests/smoke/tapes/model-manager.tape
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
# model-manager.tape — smoke the `netclaw model` TUI.
#
# Validates that the page opens (panel title renders), shows the
# model role assignment view, and exits cleanly on Ctrl+Q. This covers
# ModelManagerPage rendering regressions (including the .WithFillHeight()
# scroll fix from #1351) without requiring a live daemon.
# Validates that the page shows configured main and fallback roles, then
# exits cleanly on Ctrl+Q. This covers the initial keyed-layout refresh
# and the .WithFillHeight() scroll fix from #1351 without a live daemon.

Output "/tmp/tape-model-manager.gif"

# ─── Seed configured roles ───────────────────────────────────────────
Type "netclaw provider add smoke-models ollama --endpoint http://localhost:11434"
Enter
Wait+Screen@10s /Added provider 'smoke-models'/

Type "netclaw model set main smoke-models deepseek-v4-flash"
Enter
Wait+Screen@10s /Set main model to smoke-models\/deepseek-v4-flash/

Type "netclaw model set fallback smoke-models fallback-model"
Enter
Wait+Screen@10s /Set fallback model to smoke-models\/fallback-model/

# ─── Launch ──────────────────────────────────────────────────────────
Type "netclaw model"
Enter

# PanelNode title always renders regardless of daemon state.
Wait+Screen@10s /Model Manager/
Sleep 300ms
Wait+Screen@10s /deepseek-v4-flash/
Wait+Screen@10s /fallback-model/

# ─── Exit TUI ────────────────────────────────────────────────────────
Ctrl+Q
Expand Down
Loading