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.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 readonly IReadOnlySet<string> s_emptySkillDirs = new HashSet<string>();
 
    private static string CreateTestRepoRoot()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
        return tempDir;
    }
 
    [Fact]
    public async Task InstallAsync_WhenNpmResolveReturnsNull_ReturnsErrorMessage()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = null
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, message) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Failed, status);
            Assert.NotNull(message);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenAlreadyInstalledAtSameVersion_SkipsInstallAndInstallsSkills()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, _) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Installed, status);
            Assert.True(playwrightRunner.InstallSkillsCalled);
            Assert.False(npmRunner.PackCalled);
            Assert.False(npmRunner.InstallGlobalCalled);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_PassesRepositoryRootAsWorkingDirectory()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.True(playwrightRunner.InstallSkillsCalled);
            Assert.Equal(tempDir, playwrightRunner.InstallSkillsWorkingDirectory);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenNewerVersionInstalled_SkipsInstallAndInstallsSkills()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            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 }
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = installedVersion,
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, _) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Installed, status);
            Assert.True(playwrightRunner.InstallSkillsCalled);
            Assert.False(npmRunner.PackCalled);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenPackFails_ReturnsErrorMessage()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version },
                PackResult = null
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, message) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Failed, status);
            Assert.NotNull(message);
            Assert.True(npmRunner.PackCalled);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: 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);
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version },
                PackResult = tarballPath,
                InstallGlobalResult = true
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var repoRoot = CreateTestRepoRoot();
            try
            {
                var (status, _) = await installer.InstallAsync(repoRoot, s_emptySkillDirs, CancellationToken.None);
 
                Assert.Equal(PlaywrightInstallStatus.Installed, status);
                Assert.True(npmRunner.InstallGlobalCalled);
                Assert.True(playwrightRunner.InstallSkillsCalled);
            }
            finally
            {
                if (Directory.Exists(repoRoot))
                {
                    Directory.Delete(repoRoot, recursive: true);
                }
            }
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_UsesDownloadedTarballDigestForProvenance()
    {
        var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
        var tempDir = Directory.CreateTempSubdirectory("test-playwright-").FullName;
        var tarballPath = Path.Combine(tempDir, "package.tgz");
        var content = new byte[] { 10, 20, 30, 40, 50 };
        await File.WriteAllBytesAsync(tarballPath, content);
        var expectedIntegrity = $"sha512-{Convert.ToBase64String(SHA512.HashData(content))}";
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version },
                PackResult = tarballPath,
                InstallGlobalResult = true
            };
            var provenanceChecker = new TestNpmProvenanceChecker();
            var playwrightRunner = new TestPlaywrightCliRunner { InstallSkillsResult = true };
            var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
            var repoRoot = CreateTestRepoRoot();
 
            try
            {
                var (status, _) = await installer.InstallAsync(repoRoot, s_emptySkillDirs, CancellationToken.None);
 
                Assert.Equal(PlaywrightInstallStatus.Installed, status);
                Assert.Equal(expectedIntegrity, provenanceChecker.CapturedSriIntegrity);
            }
            finally
            {
                if (Directory.Exists(repoRoot))
                {
                    Directory.Delete(repoRoot, recursive: true);
                }
            }
        }
        finally
        {
            Directory.Delete(tempDir, recursive: true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenGlobalInstallFails_ReturnsErrorMessage()
    {
        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);
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version },
                PackResult = tarballPath,
                InstallGlobalResult = false
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var repoRoot = CreateTestRepoRoot();
            try
            {
                var (status, message) = await installer.InstallAsync(repoRoot, s_emptySkillDirs, CancellationToken.None);
 
                Assert.Equal(PlaywrightInstallStatus.Failed, status);
                Assert.NotNull(message);
            }
            finally
            {
                if (Directory.Exists(repoRoot))
                {
                    Directory.Delete(repoRoot, recursive: true);
                }
            }
        }
        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);
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = targetVersion },
                PackResult = tarballPath,
                InstallGlobalResult = true
            };
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = installedVersion,
                InstallSkillsResult = true
            };
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var repoRoot = CreateTestRepoRoot();
            try
            {
                var (status, _) = await installer.InstallAsync(repoRoot, s_emptySkillDirs, CancellationToken.None);
 
                Assert.Equal(PlaywrightInstallStatus.Installed, status);
                Assert.True(npmRunner.PackCalled);
                Assert.True(npmRunner.InstallGlobalCalled);
                Assert.True(playwrightRunner.InstallSkillsCalled);
            }
            finally
            {
                if (Directory.Exists(repoRoot))
                {
                    Directory.Delete(repoRoot, recursive: true);
                }
            }
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public void ComputeIntegrity_ReturnsSha512SriValue()
    {
        var tempDir = Directory.CreateTempSubdirectory("test-playwright-").FullName;
        var tempPath = Path.Combine(tempDir, "package.tgz");
        try
        {
            var content = "test content for hashing"u8.ToArray();
            File.WriteAllBytes(tempPath, content);
 
            var hash = SHA512.HashData(content);
            var expectedIntegrity = $"sha512-{Convert.ToBase64String(hash)}";
 
            Assert.Equal(expectedIntegrity, PlaywrightCliInstaller.ComputeIntegrity(tempPath));
        }
        finally
        {
            Directory.Delete(tempDir, recursive: true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WorkflowRefValidator_AcceptsBothTagFormats()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.1.7", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            var provenanceChecker = new TestNpmProvenanceChecker();
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.True(provenanceChecker.ProvenanceCalled);
            Assert.NotNull(provenanceChecker.CapturedValidateWorkflowRef);
 
            // Accept tags without 'v' prefix (0.1.7+)
            Assert.True(WorkflowRefInfo.TryParse($"refs/tags/{version}", out var refWithout));
            Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWithout!));
 
            // Accept tags with 'v' prefix (pre-0.1.7)
            Assert.True(WorkflowRefInfo.TryParse($"refs/tags/v{version}", out var refWith));
            Assert.True(provenanceChecker.CapturedValidateWorkflowRef(refWith!));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenProvenanceCheckFails_ReturnsErrorMessage()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            var provenanceChecker = new TestNpmProvenanceChecker { ProvenanceOutcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, provenanceChecker, playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, message) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Failed, status);
            Assert.NotNull(message);
            Assert.True(provenanceChecker.ProvenanceCalled);
            Assert.True(npmRunner.PackCalled);
            Assert.False(npmRunner.InstallGlobalCalled);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [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]);
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version },
                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 TestInteractionService(), configuration, NullLogger<PlaywrightCliInstaller>.Instance);
 
            var repoRoot = CreateTestRepoRoot();
            try
            {
                var (status, _) = await installer.InstallAsync(repoRoot, s_emptySkillDirs, CancellationToken.None);
 
                Assert.Equal(PlaywrightInstallStatus.Installed, status);
                Assert.False(provenanceChecker.ProvenanceCalled);
                Assert.True(npmRunner.PackCalled);
                Assert.True(npmRunner.InstallGlobalCalled);
            }
            finally
            {
                if (Directory.Exists(repoRoot))
                {
                    Directory.Delete(repoRoot, recursive: true);
                }
            }
        }
        finally
        {
            Directory.Delete(tempDir, true);
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenVersionOverrideConfigured_UsesOverrideVersion()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.2.0", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            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 TestInteractionService(), configuration, NullLogger<PlaywrightCliInstaller>.Instance);
 
            // PackAsync returns null by default — we only care about the resolved range
            await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal("0.2.0", npmRunner.ResolvedVersionRange);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Theory]
    [InlineData(">=0.2.0")]
    [InlineData("latest")]
    [InlineData("0.2")]
    [InlineData("not-a-version")]
    [InlineData("v0.2.0")]
    public async Task InstallAsync_WhenVersionOverrideIsNotStrictSemVer_ReturnsFailed(string invalidVersion)
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = SemVersion.Parse("0.2.0", SemVersionStyles.Strict) }
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var configuration = new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?>
                {
                    [PlaywrightCliInstaller.VersionOverrideKey] = invalidVersion
                })
                .Build();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), configuration, NullLogger<PlaywrightCliInstaller>.Instance);
 
            var (status, message) = await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightInstallStatus.Failed, status);
            Assert.NotNull(message);
            Assert.Contains(invalidVersion, message);
            Assert.Null(npmRunner.ResolvedVersionRange);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenNoVersionOverride_UsesDefaultRange()
    {
        var tempDir = CreateTestRepoRoot();
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
            var playwrightRunner = new TestPlaywrightCliRunner();
            var installer = new PlaywrightCliInstaller(npmRunner, new TestNpmProvenanceChecker(), playwrightRunner, new TestInteractionService(), new ConfigurationBuilder().Build(), NullLogger<PlaywrightCliInstaller>.Instance);
 
            // PackAsync returns null by default — we only care about the resolved range
            await installer.InstallAsync(tempDir, s_emptySkillDirs, CancellationToken.None);
 
            Assert.Equal(PlaywrightCliInstaller.VersionRange, npmRunner.ResolvedVersionRange);
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_MirrorsSkillFilesToOtherAgentEnvironments()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-mirror-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true,
                // Simulate playwright-cli writing to .claude/skills/ during install
                OnInstallSkills = repoRoot =>
                {
                    var skillDir = Path.Combine(repoRoot, ".claude", "skills", "playwright-cli");
                    Directory.CreateDirectory(skillDir);
                    File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "# Playwright CLI Skill");
                    Directory.CreateDirectory(Path.Combine(skillDir, "subdir"));
                    File.WriteAllText(Path.Combine(skillDir, "subdir", "extra.md"), "Extra content");
                }
            };
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
 
            var installer = new PlaywrightCliInstaller(
                npmRunner, new TestNpmProvenanceChecker(), playwrightRunner,
                new TestInteractionService(), new ConfigurationBuilder().Build(),
                NullLogger<PlaywrightCliInstaller>.Instance);
 
            var selectedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
            {
                Path.Combine(".claude", "skills"),
                Path.Combine(".agents", "skills"),
                Path.Combine(".github", "skills")
            };
            await installer.InstallAsync(tempDir, selectedDirs, CancellationToken.None);
 
            // Verify files were mirrored to .agents/skills/playwright-cli/
            Assert.True(File.Exists(Path.Combine(tempDir, ".agents", "skills", "playwright-cli", "SKILL.md")));
            Assert.True(File.Exists(Path.Combine(tempDir, ".agents", "skills", "playwright-cli", "subdir", "extra.md")));
            Assert.Equal("# Playwright CLI Skill", await File.ReadAllTextAsync(Path.Combine(tempDir, ".agents", "skills", "playwright-cli", "SKILL.md")));
 
            // 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")));
        }
        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);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenOnlyStandardSelected_CleansUpUnselectedLocations()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-cleanup-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true,
                // Simulate playwright-cli writing to .claude/skills/ during install
                OnInstallSkills = repoRoot =>
                {
                    var skillDir = Path.Combine(repoRoot, ".claude", "skills", "playwright-cli");
                    Directory.CreateDirectory(skillDir);
                    File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "# Playwright CLI Skill");
                }
            };
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
 
            var installer = new PlaywrightCliInstaller(
                npmRunner, new TestNpmProvenanceChecker(), playwrightRunner,
                new TestInteractionService(), new ConfigurationBuilder().Build(),
                NullLogger<PlaywrightCliInstaller>.Instance);
 
            // Only select Standard (.agents/skills)
            var selectedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
            {
                Path.Combine(".agents", "skills")
            };
            await installer.InstallAsync(tempDir, selectedDirs, CancellationToken.None);
 
            // Skill should be mirrored to the selected location
            Assert.True(File.Exists(Path.Combine(tempDir, ".agents", "skills", "playwright-cli", "SKILL.md")));
 
            // .claude/skills/playwright-cli should be cleaned up (created by playwright-cli, not selected)
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".claude", "skills", "playwright-cli")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenCleaningUp_DoesNotDeletePreExistingDirectories()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-preexist-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Pre-existing .claude/skills/playwright-cli (user already had this)
            var preExistingDir = Path.Combine(tempDir, ".claude", "skills", "playwright-cli");
            Directory.CreateDirectory(preExistingDir);
            await File.WriteAllTextAsync(Path.Combine(preExistingDir, "SKILL.md"), "# User's existing skill");
 
            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 }
            };
 
            var installer = new PlaywrightCliInstaller(
                npmRunner, new TestNpmProvenanceChecker(), playwrightRunner,
                new TestInteractionService(), new ConfigurationBuilder().Build(),
                NullLogger<PlaywrightCliInstaller>.Instance);
 
            // Only select Standard — .claude/skills is NOT selected
            var selectedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
            {
                Path.Combine(".agents", "skills")
            };
            await installer.InstallAsync(tempDir, selectedDirs, CancellationToken.None);
 
            // .claude/skills/playwright-cli should NOT be deleted because it pre-existed
            Assert.True(Directory.Exists(preExistingDir));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public async Task InstallAsync_WhenCleaningUp_RemovesEmptyParentDirectories()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-emptydir-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            var version = SemVersion.Parse("0.1.1", SemVersionStyles.Strict);
            var playwrightRunner = new TestPlaywrightCliRunner
            {
                InstalledVersion = version,
                InstallSkillsResult = true,
                // Simulate playwright-cli writing to .claude/skills/ during install
                OnInstallSkills = repoRoot =>
                {
                    var skillDir = Path.Combine(repoRoot, ".claude", "skills", "playwright-cli");
                    Directory.CreateDirectory(skillDir);
                    File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "# Playwright CLI Skill");
                }
            };
            var npmRunner = new TestNpmRunner
            {
                ResolveResult = new NpmPackageInfo { Version = version }
            };
 
            var installer = new PlaywrightCliInstaller(
                npmRunner, new TestNpmProvenanceChecker(), playwrightRunner,
                new TestInteractionService(), new ConfigurationBuilder().Build(),
                NullLogger<PlaywrightCliInstaller>.Instance);
 
            var selectedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
            {
                Path.Combine(".agents", "skills")
            };
            await installer.InstallAsync(tempDir, selectedDirs, CancellationToken.None);
 
            // The entire .claude/skills/ tree should be gone (empty after playwright-cli removal)
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".claude", "skills")));
            // .claude itself should also be gone if it's empty
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".claude")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public void RemoveEmptyParentDirectories_RemovesEmptyParentsUpToStopDir()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-rmparent-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Create .claude/skills/playwright-cli (3 levels), then remove playwright-cli
            var skillDir = Path.Combine(tempDir, ".claude", "skills", "playwright-cli");
            Directory.CreateDirectory(skillDir);
            Directory.Delete(skillDir);
 
            // Now .claude/skills is empty — RemoveEmptyParentDirectories should clean up
            PlaywrightCliInstaller.RemoveEmptyParentDirectories(
                skillDir,
                tempDir,
                Path.Combine(".claude", "skills"));
 
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".claude", "skills")));
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".claude")));
            Assert.True(Directory.Exists(tempDir)); // stop dir should NOT be deleted
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public void RemoveEmptyParentDirectories_StopsAtNonEmptyParent()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-rmparent-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Create .claude/skills/playwright-cli and .claude/skills/other-skill
            var skillDir = Path.Combine(tempDir, ".claude", "skills", "playwright-cli");
            Directory.CreateDirectory(skillDir);
            Directory.CreateDirectory(Path.Combine(tempDir, ".claude", "skills", "other-skill"));
            Directory.Delete(skillDir);
 
            PlaywrightCliInstaller.RemoveEmptyParentDirectories(
                skillDir,
                tempDir,
                Path.Combine(".claude", "skills"));
 
            // .claude/skills should still exist because it has other-skill
            Assert.True(Directory.Exists(Path.Combine(tempDir, ".claude", "skills")));
            Assert.True(Directory.Exists(Path.Combine(tempDir, ".claude", "skills", "other-skill")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public void RemoveEmptyParentDirectories_NeverDeletesStopDir()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-rmparent-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Create a single level: .agents/playwright-cli
            var skillDir = Path.Combine(tempDir, ".agents", "playwright-cli");
            Directory.CreateDirectory(skillDir);
            Directory.Delete(skillDir);
 
            PlaywrightCliInstaller.RemoveEmptyParentDirectories(
                skillDir,
                tempDir,
                ".agents");
 
            // .agents should be removed (it's empty and not the stop dir)
            Assert.False(Directory.Exists(Path.Combine(tempDir, ".agents")));
            // tempDir (stop dir) must never be removed
            Assert.True(Directory.Exists(tempDir));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    [Fact]
    public void RemoveEmptyParentDirectories_RespectsMaxDepthLimit()
    {
        var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-rmparent-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(tempDir);
 
        try
        {
            // Create a deep path: a/b/c/d/e
            var deepDir = Path.Combine(tempDir, "a", "b", "c", "d", "e");
            Directory.CreateDirectory(deepDir);
            Directory.Delete(deepDir);
            // d, c, b, a are all empty
 
            // Relative path "a/b" means maxDepth = 3 (2 segments + 1)
            // Starting from e's path, it should walk up: d (1), c (2), b (3) — stops there
            PlaywrightCliInstaller.RemoveEmptyParentDirectories(
                deepDir,
                tempDir,
                Path.Combine("a", "b"));
 
            // d, c, b should be removed (within maxDepth)
            Assert.False(Directory.Exists(Path.Combine(tempDir, "a", "b", "c", "d")));
            Assert.False(Directory.Exists(Path.Combine(tempDir, "a", "b", "c")));
            Assert.False(Directory.Exists(Path.Combine(tempDir, "a", "b")));
            // a should still exist (maxDepth exceeded)
            Assert.True(Directory.Exists(Path.Combine(tempDir, "a")));
        }
        finally
        {
            if (Directory.Exists(tempDir))
            {
                Directory.Delete(tempDir, recursive: true);
            }
        }
    }
 
    private sealed class TestNpmRunner : INpmRunner
    {
        public bool IsAvailable => true;
 
        public NpmPackageInfo? ResolveResult { get; set; }
        public string? PackResult { get; set; } = string.Empty;
        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;
            if (PackResult == string.Empty)
            {
                PackResult = Path.Combine(outputDirectory, "package.tgz");
                File.WriteAllBytes(PackResult, [1, 2, 3]);
            }
 
            return Task.FromResult(PackResult);
        }
 
        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 Func<WorkflowRefInfo, bool>? CapturedValidateWorkflowRef { get; private set; }
        public string? CapturedSriIntegrity { get; private set; }
 
        public Task<ProvenanceVerificationResult> VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func<WorkflowRefInfo, bool>? validateWorkflowRef, string? sriIntegrity, CancellationToken cancellationToken)
        {
            ProvenanceCalled = true;
            CapturedValidateWorkflowRef = validateWorkflowRef;
            CapturedSriIntegrity = sriIntegrity;
            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 string? InstallSkillsWorkingDirectory { get; private set; }
        public Action<string>? OnInstallSkills { get; set; }
 
        public Task<SemVersion?> GetVersionAsync(CancellationToken cancellationToken)
            => Task.FromResult(InstalledVersion);
 
        public Task<bool> InstallSkillsAsync(string workingDirectory, CancellationToken cancellationToken)
        {
            InstallSkillsCalled = true;
            InstallSkillsWorkingDirectory = workingDirectory;
            OnInstallSkills?.Invoke(workingDirectory);
            return Task.FromResult(InstallSkillsResult);
        }
    }
}