File: Agents\TelemetryHookInstallerTests.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.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Agents.AspireSkills;
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 TelemetryHookInstallerTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task EnsureInstalledAsync_MaterializesBothScriptsUnderAspireHooksDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var installer = CreateInstaller(workspace, home);
 
        var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
 
        var expectedDirectory = Path.Combine(home.FullName, ".aspire", "hooks");
        Assert.Equal(Path.Combine(expectedDirectory, "track-telemetry.sh"), scripts.ShellScriptPath);
        Assert.Equal(Path.Combine(expectedDirectory, "track-telemetry.ps1"), scripts.PowerShellScriptPath);
        Assert.True(File.Exists(scripts.ShellScriptPath));
        Assert.True(File.Exists(scripts.PowerShellScriptPath));
    }
 
    [Fact]
    public async Task EnsureInstalledAsync_MatchesBundledHooksAndMetadata()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var bundleDirectory = workspace.CreateDirectory("bundle");
        var bundleProvider = new EmbeddedAspireSkillsBundleProvider(
            new AspireSkillsBundleProvider(),
            NullLogger<EmbeddedAspireSkillsBundleProvider>.Instance);
        var bundle = await bundleProvider.CreateBundleAsync(bundleDirectory, CancellationToken.None).DefaultTimeout();
        Assert.NotNull(bundle);
 
        var scripts = await CreateInstaller(workspace, home).EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
        var installedPaths = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["track-telemetry.sh"] = scripts.ShellScriptPath,
            ["track-telemetry.ps1"] = scripts.PowerShellScriptPath
        };
 
        await using var manifestStream = File.OpenRead(Path.Combine(bundleDirectory.FullName, "skill-manifest.json"));
        using var manifest = await JsonDocument.ParseAsync(manifestStream).DefaultTimeout();
        await using var metadataStream = typeof(TelemetryHookInstaller).Assembly.GetManifestResourceStream("aspire-skills.metadata.json");
        Assert.NotNull(metadataStream);
        using var metadata = await JsonDocument.ParseAsync(metadataStream).DefaultTimeout();
 
        var bundledHooks = manifest.RootElement.GetProperty("hooks");
        var recordedHooks = metadata.RootElement.GetProperty("hooks");
        Assert.Equal(bundledHooks.GetProperty("commitSha").GetString(), recordedHooks.GetProperty("commitSha").GetString());
 
        var bundledFiles = bundledHooks.GetProperty("files").EnumerateObject().ToArray();
        var recordedFiles = recordedHooks.GetProperty("files");
        var expectedNames = bundledFiles.Select(file => file.Name).Order(StringComparer.Ordinal).ToArray();
        Assert.Equal(expectedNames, installedPaths.Keys.Order(StringComparer.Ordinal));
        Assert.Equal(expectedNames, recordedFiles.EnumerateObject().Select(file => file.Name).Order(StringComparer.Ordinal));
 
        foreach (var file in bundledFiles)
        {
            var bundledPath = Path.Combine(bundleDirectory.FullName, "hooks", "scripts", file.Name);
            var bundledContent = await ReadLfNormalizedAsync(bundledPath).DefaultTimeout();
            var installedContent = await ReadLfNormalizedAsync(installedPaths[file.Name]).DefaultTimeout();
            Assert.Equal(bundledContent, installedContent);
 
            var installedHash = Convert.ToHexStringLower(SHA512.HashData(Encoding.UTF8.GetBytes(installedContent)));
            Assert.Equal(file.Value.GetString(), installedHash);
            Assert.Equal(file.Value.GetString(), recordedFiles.GetProperty(file.Name).GetString());
        }
    }
 
    [Fact]
    public async Task EnsureInstalledAsync_ShellScriptUsesLfEndingsAndNoBom()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var installer = CreateInstaller(workspace, home);
 
        var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
 
        var bytes = await File.ReadAllBytesAsync(scripts.ShellScriptPath).DefaultTimeout();
        // A UTF-8 BOM (EF BB BF) before the shebang stops the kernel from honoring `#!`.
        Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF);
        Assert.DoesNotContain((byte)'\r', bytes);
 
        var content = await File.ReadAllTextAsync(scripts.ShellScriptPath).DefaultTimeout();
        Assert.StartsWith("#!", content);
    }
 
    [Fact]
    public async Task EnsureInstalledAsync_IsIdempotent_WhenContentUnchanged()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var installer = CreateInstaller(workspace, home);
 
        var first = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
        var firstShellContent = await File.ReadAllTextAsync(first.ShellScriptPath).DefaultTimeout();
 
        var second = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
        var secondShellContent = await File.ReadAllTextAsync(second.ShellScriptPath).DefaultTimeout();
 
        Assert.Equal(first.ShellScriptPath, second.ShellScriptPath);
        Assert.Equal(firstShellContent, secondShellContent);
    }
 
    [Fact]
    public async Task EnsureInstalledAsync_RewritesScript_WhenExistingContentDiffers()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var installer = CreateInstaller(workspace, home);
 
        var hooksDirectory = Path.Combine(home.FullName, ".aspire", "hooks");
        Directory.CreateDirectory(hooksDirectory);
        var shellPath = Path.Combine(hooksDirectory, "track-telemetry.sh");
        await File.WriteAllTextAsync(shellPath, "stale-content").DefaultTimeout();
 
        var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
 
        var content = await File.ReadAllTextAsync(scripts.ShellScriptPath).DefaultTimeout();
        Assert.NotEqual("stale-content", content);
        Assert.StartsWith("#!", content);
    }
 
    [Fact]
    public async Task EnsureInstalledAsync_SetsExecutableBit_OnNonWindows()
    {
        if (OperatingSystem.IsWindows())
        {
            return;
        }
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var home = workspace.CreateDirectory("home");
        var installer = CreateInstaller(workspace, home);
 
        var scripts = await installer.EnsureInstalledAsync(CancellationToken.None).DefaultTimeout();
 
        var mode = File.GetUnixFileMode(scripts.ShellScriptPath);
        Assert.True(mode.HasFlag(UnixFileMode.UserExecute));
    }
 
    private static TelemetryHookInstaller CreateInstaller(TemporaryWorkspace workspace, DirectoryInfo home)
    {
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: home);
        return new TelemetryHookInstaller(executionContext, NullLogger<TelemetryHookInstaller>.Instance);
    }
 
    private static async Task<string> ReadLfNormalizedAsync(string path)
    {
        // Git can check the PowerShell resource out with CRLF; release hook hashes use LF UTF-8 without a BOM.
        var content = await File.ReadAllTextAsync(path);
        return content.Replace("\r\n", "\n").Replace('\r', '\n');
    }
}