feat(claude-do): „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuch
Der Button, der aus „Problem erkannt" ein „Problem gelöst" macht: startet eine interaktive Claude-Session, die dem Nutzer beim Einrichten hilft. ## Warum externes Terminal Der ConPTY-Stack (`PtyTerminalSession`, `ConPtyPaneView`) liegt in `ClaudeDo.Ui` und ist Avalonia — der Installer ist WPF und referenziert nur Data/Releases/Localization. Beim Fresh Install sind `app\`/`worker\` außerdem noch n ClaudeDo-Task: 4e196058-38a3-404f-9862-4cb0e90195da
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Core;
|
||||
|
||||
public sealed class ClaudeHelpLauncherTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeHelpLauncherTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudehelp_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeHelpLauncher MakeLauncher(FakeProcessRunner? runner = null, FakeProcessLauncher? launcher = null, string? pathExtOverride = ".exe") =>
|
||||
new(runner ?? new FakeProcessRunner(), launcher ?? new FakeProcessLauncher(), pathOverride: _dir, pathExtOverride: pathExtOverride);
|
||||
|
||||
private static EnvironmentCheckReport MakeReport() => new(new[]
|
||||
{
|
||||
CheckResult.Ok(GitCheck.CheckId, CheckSeverity.Error, "checks.git.title", "C:\\Program Files\\Git\\git.exe — 2.43.0"),
|
||||
CheckResult.Ok(ClaudeCliCheck.CheckId, CheckSeverity.Error, "checks.claudeCli.title", "C:\\npm\\claude.cmd — 2.1.230"),
|
||||
CheckResult.Fail(ClaudeAuthCheck.CheckId, CheckSeverity.Error, "checks.claudeAuth.title", "Not logged in.",
|
||||
"checks.claudeAuth.hint", detail: "exit code 1: some raw CLI output"),
|
||||
});
|
||||
|
||||
// ----- BuildReportAsync -----
|
||||
|
||||
[Fact]
|
||||
public async Task Report_includes_all_check_ids_severities_and_failed_details()
|
||||
{
|
||||
var launcher = MakeLauncher();
|
||||
var report = MakeReport();
|
||||
|
||||
var text = await launcher.BuildReportAsync(report, new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Contains(GitCheck.CheckId, text);
|
||||
Assert.Contains(ClaudeCliCheck.CheckId, text);
|
||||
Assert.Contains(ClaudeAuthCheck.CheckId, text);
|
||||
Assert.Contains(CheckSeverity.Error.ToString(), text);
|
||||
Assert.Contains(CheckStatus.Failed.ToString(), text);
|
||||
Assert.Contains("exit code 1: some raw CLI output", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_does_not_include_environment_variable_values()
|
||||
{
|
||||
const string marker = "sk-super-secret-token-12345";
|
||||
Environment.SetEnvironmentVariable("CLAUDEDO_TEST_SECRET", marker);
|
||||
try
|
||||
{
|
||||
var launcher = MakeLauncher();
|
||||
var text = await launcher.BuildReportAsync(MakeReport(), new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain(marker, text);
|
||||
Assert.DoesNotContain(".credentials.json", text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLAUDEDO_TEST_SECRET", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_includes_planned_install_directory_and_ports()
|
||||
{
|
||||
var launcher = MakeLauncher();
|
||||
var ctx = new InstallContext { InstallDirectory = @"C:\Somewhere\ClaudeDo", SignalRPort = 11111, ExternalMcpPort = 22222 };
|
||||
|
||||
var text = await launcher.BuildReportAsync(MakeReport(), ctx, CancellationToken.None);
|
||||
|
||||
Assert.Contains(@"C:\Somewhere\ClaudeDo", text);
|
||||
Assert.Contains("11111", text);
|
||||
Assert.Contains("22222", text);
|
||||
}
|
||||
|
||||
// ----- LaunchTerminal -----
|
||||
|
||||
private string MakeClaudeExe()
|
||||
{
|
||||
var path = Path.Combine(_dir, "claude.exe");
|
||||
File.WriteAllText(path, "");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uses_wt_when_available()
|
||||
{
|
||||
MakeClaudeExe();
|
||||
File.WriteAllText(Path.Combine(_dir, "wt.exe"), "");
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("wt.exe", fakeLauncher.LastStartInfo!.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Falls_back_to_cmd_when_wt_is_not_available()
|
||||
{
|
||||
MakeClaudeExe();
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("cmd.exe", fakeLauncher.LastStartInfo!.FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Working_directory_is_temp_not_install_directory()
|
||||
{
|
||||
MakeClaudeExe();
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
var ctx = new InstallContext { InstallDirectory = @"C:\Program Files\ClaudeDo" };
|
||||
|
||||
launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), ctx);
|
||||
|
||||
Assert.Equal(Path.GetTempPath(), fakeLauncher.LastStartInfo!.WorkingDirectory);
|
||||
Assert.DoesNotContain(ctx.InstallDirectory, fakeLauncher.LastStartInfo!.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quotes_the_claude_path_when_it_contains_spaces()
|
||||
{
|
||||
var spacedDir = Path.Combine(_dir, "path with spaces");
|
||||
Directory.CreateDirectory(spacedDir);
|
||||
var claudePath = Path.Combine(spacedDir, "claude.exe");
|
||||
File.WriteAllText(claudePath, "");
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var runner = new FakeProcessRunner();
|
||||
var launcher = new ClaudeHelpLauncher(runner, fakeLauncher, pathOverride: spacedDir, pathExtOverride: ".exe");
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains($"\"{claudePath}\"", fakeLauncher.LastStartInfo!.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmd_shim_as_claude_works()
|
||||
{
|
||||
var claudePath = Path.Combine(_dir, "claude.cmd");
|
||||
File.WriteAllText(claudePath, "");
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher, pathExtOverride: ".cmd");
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains(claudePath, fakeLauncher.LastStartInfo!.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Claude_not_found_returns_error_result_not_exception()
|
||||
{
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), ctx);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.ErrorMessage);
|
||||
Assert.Null(fakeLauncher.LastStartInfo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_process_start_returns_error_result_not_exception()
|
||||
{
|
||||
MakeClaudeExe();
|
||||
var fakeLauncher = new FakeProcessLauncher(throwOnStart: new InvalidOperationException("boom"));
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
|
||||
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("boom", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// ----- LaunchAsync (report + terminal wiring) -----
|
||||
|
||||
[Fact]
|
||||
public async Task LaunchAsync_writes_report_and_points_the_prompt_at_it()
|
||||
{
|
||||
MakeClaudeExe();
|
||||
var fakeLauncher = new FakeProcessLauncher();
|
||||
var launcher = MakeLauncher(launcher: fakeLauncher);
|
||||
|
||||
var result = await launcher.LaunchAsync(MakeReport(), new InstallContext(), CancellationToken.None);
|
||||
|
||||
var reportPath = Path.Combine(Path.GetTempPath(), ClaudeHelpLauncher.ReportFileName);
|
||||
try
|
||||
{
|
||||
Assert.True(result.Success);
|
||||
Assert.True(File.Exists(reportPath));
|
||||
Assert.Contains(GitCheck.CheckId, await File.ReadAllTextAsync(reportPath));
|
||||
Assert.Contains(reportPath, fakeLauncher.LastStartInfo!.Arguments);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(reportPath); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Core;
|
||||
|
||||
internal sealed class FakeProcessLauncher : IProcessLauncher
|
||||
{
|
||||
private readonly Exception? _throwOnStart;
|
||||
|
||||
public FakeProcessLauncher(Exception? throwOnStart = null)
|
||||
{
|
||||
_throwOnStart = throwOnStart;
|
||||
}
|
||||
|
||||
public ProcessStartInfo? LastStartInfo { get; private set; }
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
LastStartInfo = startInfo;
|
||||
if (_throwOnStart is not null) throw _throwOnStart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Core;
|
||||
|
||||
/// <summary>A ClaudeHelpLauncher for tests that don't exercise it, just need a valid instance.</summary>
|
||||
internal static class TestClaudeHelpLauncher
|
||||
{
|
||||
public static ClaudeHelpLauncher Create() =>
|
||||
new(new FakeProcessRunner(), new FakeProcessLauncher());
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Installer.Pages.ServicePage;
|
||||
using ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
@@ -16,7 +17,7 @@ public class PageResolverTests
|
||||
var pages = new IInstallerPage[]
|
||||
{
|
||||
new WelcomePageViewModel(context),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException()),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException(), TestClaudeHelpLauncher.Create()),
|
||||
new PathsPageViewModel(context),
|
||||
new ServicePageViewModel(context),
|
||||
new UiSettingsPageViewModel(context),
|
||||
@@ -36,7 +37,7 @@ public class PageResolverTests
|
||||
public void SystemCheckPage_is_not_shown_in_settings()
|
||||
{
|
||||
var context = new InstallContext();
|
||||
var page = new SystemCheckPageViewModel(context, () => throw new InvalidOperationException());
|
||||
var page = new SystemCheckPageViewModel(context, () => throw new InvalidOperationException(), TestClaudeHelpLauncher.Create());
|
||||
|
||||
Assert.False(page.ShowInSettings);
|
||||
}
|
||||
|
||||
+65
-2
@@ -1,13 +1,14 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
using Vm = ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageViewModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
|
||||
|
||||
public sealed class SystemCheckPageViewModelTests
|
||||
{
|
||||
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory) =>
|
||||
new(new InstallContext(), factory);
|
||||
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory, ClaudeHelpLauncher? claudeHelpLauncher = null, InstallContext? context = null) =>
|
||||
new(context ?? new InstallContext(), factory, claudeHelpLauncher ?? TestClaudeHelpLauncher.Create());
|
||||
|
||||
private static Func<EnvironmentCheckService> FactoryFor(params IEnvironmentCheck[] checks) =>
|
||||
() => new EnvironmentCheckService(checks);
|
||||
@@ -169,4 +170,66 @@ public sealed class SystemCheckPageViewModelTests
|
||||
gate.SetResult();
|
||||
await run;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaudeHelp_disabled_when_claude_cli_check_failed()
|
||||
{
|
||||
var vm = CreateViewModel(FactoryFor(
|
||||
FakeEnvironmentCheck.Fail(ClaudeCliCheck.CheckId, CheckSeverity.Error),
|
||||
FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)));
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.False(vm.CanStartClaudeHelp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaudeHelp_disabled_when_claude_auth_check_failed()
|
||||
{
|
||||
var vm = CreateViewModel(FactoryFor(
|
||||
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
|
||||
FakeEnvironmentCheck.Fail(ClaudeAuthCheck.CheckId, CheckSeverity.Error)));
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.False(vm.CanStartClaudeHelp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaudeHelp_enabled_when_both_checks_ok()
|
||||
{
|
||||
var vm = CreateViewModel(FactoryFor(
|
||||
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
|
||||
FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)));
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.CanStartClaudeHelp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaudeHelp_stays_enabled_when_auth_check_is_unknown()
|
||||
{
|
||||
var vm = CreateViewModel(FactoryFor(
|
||||
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
|
||||
FakeEnvironmentCheck.Unknown(ClaudeAuthCheck.CheckId)));
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.CanStartClaudeHelp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaudeHelp_start_failure_sets_error_message_without_throwing()
|
||||
{
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
var vm = CreateViewModel(
|
||||
FactoryFor(FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId), FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)),
|
||||
context: ctx);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.StartClaudeHelpCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(vm.ClaudeHelpError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using ClaudeDo.Installer.Pages.ServicePage;
|
||||
using ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
using ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -20,7 +21,7 @@ public class WizardViewModelTests
|
||||
return new IInstallerPage[]
|
||||
{
|
||||
new WelcomePageViewModel(context),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException("not needed for this test")),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException("not needed for this test"), TestClaudeHelpLauncher.Create()),
|
||||
new PathsPageViewModel(context),
|
||||
new ServicePageViewModel(context),
|
||||
new UiSettingsPageViewModel(context),
|
||||
@@ -65,7 +66,7 @@ public class WizardViewModelTests
|
||||
? FakeEnvironmentCheck.Fail("git", CheckSeverity.Error)
|
||||
: FakeEnvironmentCheck.Ok("git");
|
||||
return new EnvironmentCheckService(new[] { check });
|
||||
});
|
||||
}, TestClaudeHelpLauncher.Create());
|
||||
|
||||
var resolver = new PageResolver(new IInstallerPage[]
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user