File: Agents\TelemetryHookConfiguratorTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json.Nodes;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.Hooks;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Agents;
 
public class TelemetryHookConfiguratorTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task ConfigureAsync_WritesCopilotUserHook_WithExpectedShape()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var configurator = CreateConfigurator(workspace, home);
 
        var result = await configurator.ConfigureAsync([AgentClientKind.CopilotCli], CancellationToken.None).DefaultTimeout();
 
        Assert.Contains(AgentClientKind.CopilotCli, result.ConfiguredClients);
        Assert.Empty(result.Skipped);
 
        var hookFile = Path.Combine(home.FullName, ".copilot", "hooks", "aspire-telemetry.json");
        Assert.True(File.Exists(hookFile));
 
        var root = JsonNode.Parse(await File.ReadAllTextAsync(hookFile).DefaultTimeout())!.AsObject();
        Assert.Equal(1, (int)root["version"]!);
 
        var entry = root["hooks"]!["postToolUse"]!.AsArray()[0]!.AsObject();
        Assert.Equal("command", (string)entry["type"]!);
        Assert.Equal(30, (int)entry["timeoutSec"]!);
        Assert.Contains("track-telemetry.sh", (string)entry["bash"]!);
        Assert.StartsWith("bash ", (string)entry["bash"]!);
        Assert.Contains("track-telemetry.ps1", (string)entry["powershell"]!);
        Assert.Contains("-File ", (string)entry["powershell"]!);
        // Copilot CLI requires PowerShell 7+ on Windows, so the hook must invoke pwsh, not Windows PowerShell.
        Assert.StartsWith("pwsh ", (string)entry["powershell"]!);
    }
 
    [Fact]
    public async Task ConfigureAsync_HonorsCopilotHomeEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var copilotHome = workspace.CreateDirectory("custom-copilot");
        var configurator = CreateConfigurator(workspace, home, new Dictionary<string, string?>
        {
            ["COPILOT_HOME"] = copilotHome.FullName,
        });
 
        await configurator.ConfigureAsync([AgentClientKind.CopilotCli], CancellationToken.None).DefaultTimeout();
 
        Assert.True(File.Exists(Path.Combine(copilotHome.FullName, "hooks", "aspire-telemetry.json")));
        Assert.False(Directory.Exists(Path.Combine(home.FullName, ".copilot")));
    }
 
    [Fact]
    public async Task ConfigureAsync_WritesClaudeUserHook_WithTimeout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var configurator = CreateConfigurator(workspace, home);
 
        var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        Assert.Contains(AgentClientKind.ClaudeCode, result.ConfiguredClients);
        Assert.Empty(result.Skipped);
 
        var postToolUse = await ReadClaudePostToolUseAsync(home).DefaultTimeout();
        var ourGroups = CountAspireGroups(postToolUse);
        Assert.Equal(1, ourGroups);
 
        var entry = FindAspireHook(postToolUse);
        Assert.Equal("command", (string)entry["type"]!);
        Assert.Equal(30, (int)entry["timeout"]!);
 
        // Claude uses exec form (command + args): the executable is spawned directly and the script path is
        // a discrete argument, not part of a shell command string.
        var execCommand = (string)entry["command"]!;
        var args = entry["args"]!.AsArray().Select(a => (string)a!).ToArray();
        if (OperatingSystem.IsWindows())
        {
            Assert.Equal("pwsh", execCommand);
            Assert.Contains("-File", args);
            Assert.Contains(args, a => a.EndsWith("track-telemetry.ps1", StringComparison.OrdinalIgnoreCase));
        }
        else
        {
            Assert.Equal("bash", execCommand);
            Assert.Contains(args, a => a.EndsWith("track-telemetry.sh", StringComparison.OrdinalIgnoreCase));
        }
    }
 
    [Fact]
    public async Task ConfigureAsync_IsIdempotent_ForClaude()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var configurator = CreateConfigurator(workspace, home);
 
        await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
        await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        var postToolUse = await ReadClaudePostToolUseAsync(home).DefaultTimeout();
        Assert.Equal(1, CountAspireGroups(postToolUse));
    }
 
    [Fact]
    public async Task ConfigureAsync_PreservesExistingClaudeConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
        var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
 
        var existing = new JsonObject
        {
            ["model"] = "claude-opus",
            ["hooks"] = new JsonObject
            {
                ["PostToolUse"] = new JsonArray(
                    new JsonObject
                    {
                        ["matcher"] = "Write",
                        ["hooks"] = new JsonArray(
                            new JsonObject
                            {
                                ["type"] = "command",
                                ["command"] = "echo existing",
                            }),
                    }),
            },
        };
        await File.WriteAllTextAsync(settingsPath, existing.ToJsonString()).DefaultTimeout();
 
        var configurator = CreateConfigurator(workspace, home);
        await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath).DefaultTimeout())!.AsObject();
        Assert.Equal("claude-opus", (string)root["model"]!);
 
        var postToolUse = root["hooks"]!["PostToolUse"]!.AsArray();
        Assert.Contains(postToolUse, group => GroupContainsCommand(group, "echo existing"));
        Assert.Equal(1, CountAspireGroups(postToolUse));
    }
 
    [Fact]
    public async Task ConfigureAsync_SkipsClaude_WhenSettingsAreMalformed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
        var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
        const string malformed = "{ this is not valid json";
        await File.WriteAllTextAsync(settingsPath, malformed).DefaultTimeout();
 
        var configurator = CreateConfigurator(workspace, home);
        var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        Assert.DoesNotContain(AgentClientKind.ClaudeCode, result.ConfiguredClients);
        Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.MalformedConfig);
        // The malformed file must be left untouched, never clobbered.
        Assert.Equal(malformed, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
    }
 
    [Fact]
    public async Task ConfigureAsync_SkipsClaude_WhenHooksShapeIsUnexpected()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
        var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
        const string unexpected = "{\"hooks\":\"not-an-object\"}";
        await File.WriteAllTextAsync(settingsPath, unexpected).DefaultTimeout();
 
        var configurator = CreateConfigurator(workspace, home);
        var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.UnexpectedConfigShape);
        Assert.Equal(unexpected, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
    }
 
    [Fact]
    public async Task ConfigureAsync_SkipsClaude_WhenSettingsRootIsNotAnObject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var claudeDirectory = Directory.CreateDirectory(Path.Combine(home.FullName, ".claude"));
        var settingsPath = Path.Combine(claudeDirectory.FullName, "settings.json");
        // Valid JSON, but the root is an array rather than an object. JsonNode.AsObject() throws
        // InvalidOperationException on this input, so the configurator must skip it like any other
        // unrecognized shape instead of letting that exception crash `agent init`.
        const string nonObjectRoot = "[1, 2, 3]";
        await File.WriteAllTextAsync(settingsPath, nonObjectRoot).DefaultTimeout();
 
        var configurator = CreateConfigurator(workspace, home);
        var result = await configurator.ConfigureAsync([AgentClientKind.ClaudeCode], CancellationToken.None).DefaultTimeout();
 
        Assert.Contains(result.Skipped, s => s.Client == AgentClientKind.ClaudeCode && s.Reason == TelemetryHookSkipReason.UnexpectedConfigShape);
        Assert.Equal(nonObjectRoot, await File.ReadAllTextAsync(settingsPath).DefaultTimeout());
    }
 
    [Fact]
    public async Task ConfigureAsync_IsNoOp_ForUnsupportedClients()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var configurator = CreateConfigurator(workspace, home);
 
        var result = await configurator.ConfigureAsync(
            [AgentClientKind.VsCode, AgentClientKind.OpenCode],
            CancellationToken.None).DefaultTimeout();
 
        Assert.Empty(result.ConfiguredClients);
        Assert.Empty(result.Skipped);
        // Nothing is materialized when no supported client is present.
        Assert.False(Directory.Exists(Path.Combine(home.FullName, ".aspire", "hooks")));
        Assert.False(Directory.Exists(Path.Combine(home.FullName, ".copilot")));
        Assert.False(Directory.Exists(Path.Combine(home.FullName, ".claude")));
    }
 
    private static async Task<JsonArray> ReadClaudePostToolUseAsync(DirectoryInfo home)
    {
        var settingsPath = Path.Combine(home.FullName, ".claude", "settings.json");
        var root = JsonNode.Parse(await File.ReadAllTextAsync(settingsPath))!.AsObject();
        return root["hooks"]!["PostToolUse"]!.AsArray();
    }
 
    private static int CountAspireGroups(JsonArray postToolUse)
        => postToolUse.Count(GroupContainsAspireHook);
 
    private static bool GroupContainsAspireHook(JsonNode? group)
        => group is JsonObject obj
            && obj["hooks"] is JsonArray hooks
            && hooks.Any(HookReferencesTelemetryScript);
 
    // The Aspire hook can carry the script path in the shell-form `command` string or, for Claude's exec
    // form, in an `args` element. Check both so helpers locate the entry regardless of format.
    private static bool HookReferencesTelemetryScript(JsonNode? hook)
        => hook is JsonObject ho
            && (JsonValueHasTelemetryScript(ho["command"])
                || (ho["args"] is JsonArray args && args.Any(JsonValueHasTelemetryScript)));
 
    private static bool JsonValueHasTelemetryScript(JsonNode? node)
        => node is JsonValue v && v.ToString().Contains("track-telemetry", StringComparison.OrdinalIgnoreCase);
 
    private static bool GroupContainsCommand(JsonNode? group, string command)
        => group is JsonObject obj
            && obj["hooks"] is JsonArray hooks
            && hooks.Any(h => h is JsonObject ho
                && ho["command"] is JsonValue v
                && v.ToString() == command);
 
    private static JsonObject FindAspireHook(JsonArray postToolUse)
    {
        foreach (var group in postToolUse)
        {
            if (group is JsonObject obj && obj["hooks"] is JsonArray hooks)
            {
                foreach (var hook in hooks)
                {
                    if (hook is JsonObject ho && HookReferencesTelemetryScript(ho))
                    {
                        return ho;
                    }
                }
            }
        }
 
        throw new InvalidOperationException("No Aspire hook entry was found.");
    }
 
    private static TelemetryHookConfigurator CreateConfigurator(
        TemporaryWorkspace workspace,
        DirectoryInfo home,
        IReadOnlyDictionary<string, string?>? environmentVariables = null)
    {
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            workspace.WorkspaceRoot,
            homeDirectory: home);
        var environment = new TestEnvironment(environmentVariables);
        var installer = new TelemetryHookInstaller(executionContext, NullLogger<TelemetryHookInstaller>.Instance);
        return new TelemetryHookConfigurator(installer, executionContext, environment, NullLogger<TelemetryHookConfigurator>.Instance);
    }
}