// 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);
}
}
}