File: Agents\PlaywrightCliInstallerTests.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 Aspire.Cli.Agents;
using Aspire.Cli.Agents.Playwright;
using Aspire.Cli.Npm;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Semver;
 
namespace Aspire.Cli.Tests.Agents;
 
public class PlaywrightCliInstallerTests
{
    private static AgentEnvironmentScanContext CreateTestContext()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        return new AgentEnvironmentScanContext
        {
            WorkingDirectory = new DirectoryInfo(tempDir),
            RepositoryRoot = new DirectoryInfo(tempDir)
        };
    }
 
    [Fact]
    public async Task InstallAsync_WhenNpmResolveReturnsNull_ReturnsFalse()
    {
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = null
        };
        var playwrightRunner = new TestPlaywrightCliRunner();
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.False(result);
    }
 
    [Fact]
    public async Task InstallAsync_WhenAlreadyInstalledAtSameVersion_SkipsInstallAndInstallsSkills()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
        };
        var playwrightRunner = new TestPlaywrightCliRunner
        {
            InstalledVersion = version,
            InstallSkillsResult = true
        };
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.True(result);
        Assert.True(playwrightRunner.InstallSkillsCalled);
        Assert.False(npmRunner.PackCalled);
        Assert.False(npmRunner.InstallGlobalCalled);
    }
 
    [Fact]
    public async Task InstallAsync_WhenNewerVersionInstalled_SkipsInstallAndInstallsSkills()
    {
        var targetVersion = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var installedVersion = SemVersion.Parse("0.2.0", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = targetVersion, Integrity = "sha512-abc123" }
        };
        var playwrightRunner = new TestPlaywrightCliRunner
        {
            InstalledVersion = installedVersion,
            InstallSkillsResult = true
        };
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.True(result);
        Assert.True(playwrightRunner.InstallSkillsCalled);
        Assert.False(npmRunner.PackCalled);
    }
 
    [Fact]
    public async Task InstallAsync_WhenPackFails_ReturnsFalse()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" },
            PackResult = null
        };
        var playwrightRunner = new TestPlaywrightCliRunner();
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.False(result);
        Assert.True(npmRunner.PackCalled);
    }
 
    [Fact]
    public async Task InstallAsync_WhenIntegrityCheckFails_ReturnsFalse()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        // Create a temp file with known content and a non-matching hash
        var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        await File.WriteAllBytesAsync(tarballPath, [1, 2, 3]);
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-definitelyWrongHash" },
                PackResult = tarballPath
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
            Assert.False(result);
            Assert.False(npmRunner.InstallGlobalCalled);
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenIntegrityCheckPasses_InstallsGlobally()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        var content = new byte[] { 10, 20, 30, 40, 50 };
        await File.WriteAllBytesAsync(tarballPath, content);
 
        // Compute the correct SRI hash for the content
        var hash = SHA512.HashData(content);
        var integrity = $"sha512-{Convert.ToBase64String(hash)}";
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version, Integrity = integrity },
                PackResult = tarballPath,
                InstallGlobalResult = true
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
            Assert.True(result);
            Assert.True(npmRunner.InstallGlobalCalled);
            Assert.True(playwrightRunner.InstallSkillsCalled);
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenGlobalInstallFails_ReturnsFalse()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        var content = new byte[] { 10, 20, 30 };
        await File.WriteAllBytesAsync(tarballPath, content);
 
        var hash = SHA512.HashData(content);
        var integrity = $"sha512-{Convert.ToBase64String(hash)}";
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version, Integrity = integrity },
                PackResult = tarballPath,
                InstallGlobalResult = false
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
            Assert.False(result);
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenOlderVersionInstalled_PerformsUpgrade()
    {
        var targetVersion = SemVersion.Parse("0.1.2", SemVersionStyles.Strict);
        var installedVersion = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        var content = new byte[] { 99, 100 };
        await File.WriteAllBytesAsync(tarballPath, content);
 
        var hash = SHA512.HashData(content);
        var integrity = $"sha512-{Convert.ToBase64String(hash)}";
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = targetVersion, Integrity = integrity },
                PackResult = tarballPath,
                InstallGlobalResult = true
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = installedVersion,
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
            Assert.True(result);
            Assert.True(npmRunner.PackCalled);
            Assert.True(npmRunner.InstallGlobalCalled);
            Assert.True(playwrightRunner.InstallSkillsCalled);
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public void VerifyIntegrity_WithMatchingHash_ReturnsTrue()
    {
        var tempPath = Path.GetTempFileName();
        try
        {
            var content = "test content for hashing"u8.ToArray();
            File.WriteAllBytes(tempPath, content);
 
            var hash = SHA512.HashData(content);
            var integrity = $"sha512-{Convert.ToBase64String(hash)}";
 
            Assert.True(PlaywrightCliInstaller.VerifyIntegrity(tempPath, integrity));
        }
        finally
        {
            File.Delete(tempPath);
        }
    }
 
    [Fact]
    public void VerifyIntegrity_WithNonMatchingHash_ReturnsFalse()
    {
        var tempPath = Path.GetTempFileName();
        try
        {
            File.WriteAllText(tempPath, "some content");
 
            Assert.False(PlaywrightCliInstaller.VerifyIntegrity(tempPath, "sha512-wronghash"));
        }
        finally
        {
            File.Delete(tempPath);
        }
    }
 
    [Fact]
    public void VerifyIntegrity_WithNonSha512Prefix_ReturnsFalse()
    {
        var tempPath = Path.GetTempFileName();
        try
        {
            File.WriteAllText(tempPath, "some content");
 
            Assert.False(PlaywrightCliInstaller.VerifyIntegrity(tempPath, "sha256-somehash"));
        }
        finally
        {
            File.Delete(tempPath);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsFalse()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
        };
        var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch };
        var playwrightRunner = new TestPlaywrightCliRunner();
        var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.False(result);
        Assert.True(provenanceChecker.ProvenanceCalled);
        Assert.False(npmRunner.PackCalled);
    }
 
    [Fact]
    public async Task InstallAsync_WhenValidationDisabled_SkipsAllValidationChecks()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var tempDir = Path.Combine(Path.GetTempPath(), $"test-playwright-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        await File.WriteAllBytesAsync(tarballPath, [10, 20, 30]);
 
        // Use a mismatched integrity hash — validation is disabled so it should still succeed.
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-wronghash" },
                PackResult = tarballPath
            };
            var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.AttestationFetchFailed };
            var playwrightRunner = new TestPlaywrightCliRunner { InstallSkillsResult = true };
            var configuration = new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?>
                {
                    [PlaywrightCliInstaller.DisablePackageValidationKey] = "true"
                })
                .Build();
            var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger<PlaywrightCliInstaller>.Instance);
 
            var result = await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
            Assert.True(result);
            Assert.False(provenanceChecker.ProvenanceCalled);
            Assert.True(npmRunner.PackCalled);
            Assert.True(npmRunner.InstallGlobalCalled);
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenVersionOverrideConfigured_UsesOverrideVersion()
    {
        var version = SemVersion.Parse("0.2.0", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
        };
        var playwrightRunner = new TestPlaywrightCliRunner();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [PlaywrightCliInstaller.VersionOverrideKey] = "0.2.0"
            })
            .Build();
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), configuration, NullLogger<PlaywrightCliInstaller>.Instance);
 
        await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.Equal("0.2.0", npmRunner.ResolvedVersionRange);
    }
 
    [Fact]
    public async Task InstallAsync_WhenNoVersionOverride_UsesDefaultRange()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var npmRunner = new TestNpmRunner
        {
            ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
        };
        var playwrightRunner = new TestPlaywrightCliRunner();
        var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestConsoleInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
        await installer.InstallAsync(CreateTestContext(), CancellationToken.None);
 
        Assert.Equal(PlaywrightCliInstaller.VersionRange, npmRunner.ResolvedVersionRange);
    }
 
    [Fact]
    public async Task InstallAsync_MirrorsSkillFilesToOtherAgentEnvironments()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-mirror-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Set up the primary skill directory with a skill file (simulating playwright-cli output)
            var primarySkillDir = Path.Combine(tempDir, ".claude", "skills", "playwright-cli");
            Directory.CreateDirectory(primarySkillDir);
            await File.WriteAllTextAsync(Path.Combine(primarySkillDir, "SKILL.md"), "# Playwright CLI Skill");
            Directory.CreateDirectory(Path.Combine(primarySkillDir, "subdir"));
            await File.WriteAllTextAsync(Path.Combine(primarySkillDir, "subdir", "extra.md"), "Extra content");
 
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true
            };
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version, Integrity = "sha512-abc123" }
            };
 
            var installer = new PlaywrightCliInstaller(
                npmRunner, new TestNpmProvenanceChecker(), playwrightRunner,
                new TestConsoleInteractionService(), new ConfigurationBuilder().Build(),
                NullLogger<PlaywrightCliInstaller>.Instance);
 
            var context = new AgentEnvironmentScanContext
            {
                WorkingDirectory = new DirectoryInfo(tempDir),
                RepositoryRoot = new DirectoryInfo(tempDir)
            };
            context.AddSkillBaseDirectory(Path.Combine(".claude", "skills"));
            context.AddSkillBaseDirectory(Path.Combine(".github", "skills"));
            context.AddSkillBaseDirectory(Path.Combine(".opencode", "skill"));
 
            await installer.InstallAsync(context, CancellationToken.None);
 
            // Verify files were mirrored to .github/skills/playwright-cli/
            Assert.True(File.Exists(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "SKILL.md")));
            Assert.True(File.Exists(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "subdir", "extra.md")));
            Assert.Equal("# Playwright CLI Skill", await File.ReadAllTextAsync(Path.Combine(tempDir, ".github", "skills", "playwright-cli", "SKILL.md")));
 
            // Verify files were mirrored to .opencode/skill/playwright-cli/
            Assert.True(File.Exists(Path.Combine(tempDir, ".opencode", "skill", "playwright-cli", "SKILL.md")));
            Assert.True(File.Exists(Path.Combine(tempDir, ".opencode", "skill", "playwright-cli", "subdir", "extra.md")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public void SyncDirectory_RemovesExtraFilesInTarget()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-sync-test-{Guid.NewGuid():N}");
        var sourceDir = Path.Combine(tempDir, "source");
        var targetDir = Path.Combine(tempDir, "target");
 
        try
        {
            // Set up source with one file
            Directory.CreateDirectory(sourceDir);
            File.WriteAllText(Path.Combine(sourceDir, "keep.md"), "keep");
 
            // Set up target with an extra file that should be removed
            Directory.CreateDirectory(targetDir);
            File.WriteAllText(Path.Combine(targetDir, "keep.md"), "old content");
            File.WriteAllText(Path.Combine(targetDir, "stale.md"), "should be removed");
            Directory.CreateDirectory(Path.Combine(targetDir, "stale-dir"));
            File.WriteAllText(Path.Combine(targetDir, "stale-dir", "old.md"), "should be removed");
 
            PlaywrightCliInstaller.SyncDirectory(sourceDir, targetDir);
 
            // Source file should be copied
            Assert.Equal("keep", File.ReadAllText(Path.Combine(targetDir, "keep.md")));
 
            // Stale files and directories should be removed
            Assert.False(File.Exists(Path.Combine(targetDir, "stale.md")));
            Assert.False(Directory.Exists(Path.Combine(targetDir, "stale-dir")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    private sealed class TestNpmRunner : INpmRunner
    {
        public NpmPackageInfo? ResolveResult { get; set; }
        public string? PackResult { get; set; }
        public bool AuditResult { get; set; } = true;
        public bool InstallGlobalResult { get; set; } = true;
 
        public bool PackCalled { get; private set; }
        public bool InstallGlobalCalled { get; private set; }
        public string? ResolvedVersionRange { get; private set; }
 
        public Task<NpmPackageInfo?> ResolvePackageAsync(string packageName, string versionRange, CancellationToken cancellationToken)
        {
            ResolvedVersionRange = versionRange;
            return Task.FromResult(ResolveResult);
        }
 
        public Task<string?> PackAsync(string packageName, string version, string outputDirectory, CancellationToken cancellationToken)
        {
            PackCalled = true;
            return Task.FromResult(PackResult);
        }
 
        public Task<bool> AuditSignaturesAsync(string packageName, string version, CancellationToken cancellationToken)
            => Task.FromResult(AuditResult);
 
        public Task<bool> InstallGlobalAsync(string tarballPath, CancellationToken cancellationToken)
        {
            InstallGlobalCalled = true;
            return Task.FromResult(InstallGlobalResult);
        }
    }
 
    private sealed class TestNpmProvenanceChecker : INpmProvenanceChecker
    {
        public ProvenanceVerificationOutcome ProvenanceOutcome { get; set; } = ProvenanceVerificationOutcome.Verified;
        public bool ProvenanceCalled { get; private set; }
 
        public Task<ProvenanceVerificationResult> VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func<WorkflowRefInfo, bool>? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null)
        {
            ProvenanceCalled = true;
            return Task.FromResult(new ProvenanceVerificationResult
            {
                Outcome = ProvenanceOutcome,
                Provenance = ProvenanceOutcome is ProvenanceVerificationOutcome.Verified
                    ? new NpmProvenanceData { SourceRepository = expectedSourceRepository }
                    : new NpmProvenanceData()
            });
        }
    }
 
    private sealed class TestPlaywrightCliRunner : IPlaywrightCliRunner
    {
        public SemVersion? InstalledVersion { get; set; }
        public bool InstallSkillsResult { get; set; }
        public bool InstallSkillsCalled { get; private set; }
 
        public Task<SemVersion?> GetVersionAsync(CancellationToken cancellationToken)
            => Task.FromResult(InstalledVersion);
 
        public Task<bool> InstallSkillsAsync(CancellationToken cancellationToken)
        {
            InstallSkillsCalled = true;
            return Task.FromResult(InstallSkillsResult);
        }
    }
}