diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1644d9..a3e9d1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,6 @@ on: tags: - 'v*' -permissions: - contents: write - concurrency: group: release-${{ github.ref }} cancel-in-progress: false @@ -16,6 +13,11 @@ jobs: publish: runs-on: ubuntu-latest if: github.event.repository.fork == false + # Must match the environment in the nuget.org Trusted Publishing policy (see docs/RELEASING.md). + environment: release + permissions: + contents: write # GitHub Release + id-token: write # OIDC token exchanged for a short-lived NuGet key steps: - uses: actions/checkout@v4 @@ -74,16 +76,25 @@ jobs: - name: Pack NuGet packages run: dotnet pack ShellUI.slnx --no-build --configuration Release -p:ContinuousIntegrationBuild=true + # Trusted Publishing: no stored key; the key this returns expires after an hour. + - name: NuGet login + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + # Core and Templates are internal; only the CLI and Components are published. - name: Publish to NuGet + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: | - dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate if ls src/ShellUI.CLI/bin/Release/*.snupkg 1> /dev/null 2>&1; then - dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate fi - dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate if ls src/ShellUI.Components/bin/Release/*.snupkg 1> /dev/null 2>&1; then - dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate fi - name: Create GitHub Release diff --git a/Directory.Build.props b/Directory.Build.props index 853de07..d87b4bc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ - 0.3.4 + 0.3.5 diff --git a/README.md b/README.md index a3c7925..bf0c27a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ | Channel | Version | Notes | |---|---|---| -| Latest stable (recommended) | `0.3.4` | Tailwind CSS `4.3.2` | +| Latest stable (recommended) | `0.3.5` | Tailwind CSS `4.3.2` | | Previous stable | `0.2.1` | Superseded by `0.3.0` | The CLI needs the .NET 10 SDK and works in Blazor projects on .NET 8, 9 and 10. The `ShellUI.Components` NuGet package targets .NET 10 only; on .NET 8 or 9, use the CLI. @@ -171,6 +171,7 @@ ShellUI uses semantic Blazor and Tailwind patterns, but accessibility depends on - [Quick start](docs/QUICKSTART.md) - [CLI syntax](docs/CLI_SYNTAX.md) - [Tailwind setup](docs/tailwind-setup.md) +- [Using ShellUI from a Razor class library](docs/razor-class-library.md) - [Contributing](docs/CONTRIBUTING.md) - [Release notes](docs/RELEASE_NOTES.md) diff --git a/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt new file mode 100644 index 0000000..de3163d --- /dev/null +++ b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt @@ -0,0 +1,23 @@ + + + + $(MSBuildProjectDirectory)\.shellui\bin + $(ShellUIBinPath)\tailwindcss.exe + $(ShellUIBinPath)/tailwindcss + $(MSBuildProjectDirectory)\wwwroot\input.css + $(MSBuildProjectDirectory)\wwwroot\app.css + --minify + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt new file mode 100644 index 0000000..3595079 --- /dev/null +++ b/ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt @@ -0,0 +1,38 @@ + + + + $(MSBuildProjectDirectory)\.shellui\bin + $(ShellUIBinPath)\tailwindcss.exe + $(ShellUIBinPath)/tailwindcss + $(MSBuildProjectDirectory)\wwwroot\input.css + $(MSBuildProjectDirectory)\wwwroot\app.css + --minify + + v4.3.2 + $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) + windows-$(TailwindArch).exe + linux-$(TailwindArch) + macos-$(TailwindArch) + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ShellUI.Tests/PackageBehaviorTests.cs b/ShellUI.Tests/PackageBehaviorTests.cs index 5bce648..1de47fc 100644 --- a/ShellUI.Tests/PackageBehaviorTests.cs +++ b/ShellUI.Tests/PackageBehaviorTests.cs @@ -63,6 +63,59 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys() Assert.Equal(new[] { false }, changes); } + [Fact] + public void DialogContent_TrapsFocusWhileOpenAndReleasesItOnClose() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var dialog = ctx.Render(p => p + .Add(c => c.Open, true) + .AddChildContent(c => c.AddChildContent("Body"))); + + var trap = ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + ctx.JSInterop.VerifyNotInvoke("ShellUI.releaseFocus"); + + dialog.Render(p => p.Add(c => c.Open, false)); + + var release = ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus"); + Assert.Equal(trap.Arguments[0], release.Arguments[0]); + } + + [Fact] + public void SheetContent_TrapsFocusWhileOpen() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + ctx.Render(p => p + .Add(c => c.Open, true) + .AddChildContent(c => c.AddChildContent("Body"))); + + ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + } + + [Fact] + public void LegacySheet_TrapsFocusAndReleasesItWhenEscapeCloses() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + bool? open = null; + + var sheet = ctx.Render(p => p + .Add(c => c.Open, true) + .Add(c => c.Title, "Settings") + .Add(c => c.OpenChanged, EventCallback.Factory.Create(this, v => open = v)) + .AddChildContent("Body")); + + ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + + sheet.Find("[role=dialog]").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.False(open); + ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus"); + } + [Fact] public void TimePicker_ShowsASetValue() { diff --git a/ShellUI.Tests/PackageVersionTests.cs b/ShellUI.Tests/PackageVersionTests.cs new file mode 100644 index 0000000..63ff559 --- /dev/null +++ b/ShellUI.Tests/PackageVersionTests.cs @@ -0,0 +1,74 @@ +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class PackageVersionTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIPackages_{Guid.NewGuid():N}"); + + public PackageVersionTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, true); + + [Theory] + [InlineData("7.0.0", "6.0.2", true)] + [InlineData("6.0.2", "6.0.2", true)] + [InlineData("6.1", "6.0.2", true)] + [InlineData("6.0.1", "6.0.2", false)] + [InlineData("5.9.9", "6.0.2", false)] + [InlineData("6.0.2-beta.1", "6.0.2", false)] + [InlineData("6.0.2", "6.0.2-beta.1", true)] + [InlineData("6.0.2-rc.1", "6.0.2-beta.1", true)] + [InlineData("7.*", "6.0.2", true)] + [InlineData("[6.0,8.0)", "6.0.2", true)] + [InlineData("$(ApexVersion)", "6.0.2", true)] + [InlineData("", "6.0.2", true)] + public void SatisfiesMinimum_OnlyAddsOrRaises(string existing, string minimum, bool satisfied) => + Assert.Equal(satisfied, ComponentInstaller.SatisfiesMinimum(existing, minimum)); + + [Fact] + public void ReadsTheVersionAttribute_CaseInsensitively() + { + var project = Project(@""); + + Assert.Equal("7.0.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts")); + Assert.Null(ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + } + + [Fact] + public void ReadsAVersionElement() + { + var project = Project(@"1.9.0"); + + Assert.Equal("1.9.0", ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + } + + [Fact] + public void ReadsCentralPackageManagement() + { + File.WriteAllText(Path.Combine(_dir, "Directory.Packages.props"), + @""); + var project = Project(@"", subfolder: "src"); + + Assert.Equal("7.1.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts")); + } + + [Fact] + public void ReferenceWithoutAFindableVersion_IsKeptAsIs() + { + var project = Project(@""); + + Assert.Equal("", ComponentInstaller.ExistingPackageVersion(project, "QRCoder")); + Assert.True(ComponentInstaller.SatisfiesMinimum("", "1.8.0")); + } + + private string Project(string references, string? subfolder = null) + { + var dir = subfolder == null ? _dir : Path.Combine(_dir, subfolder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "App.csproj"); + File.WriteAllText(path, $@"{references}"); + return path; + } +} diff --git a/ShellUI.Tests/RazorClassLibraryTests.cs b/ShellUI.Tests/RazorClassLibraryTests.cs new file mode 100644 index 0000000..6967550 --- /dev/null +++ b/ShellUI.Tests/RazorClassLibraryTests.cs @@ -0,0 +1,78 @@ +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class RazorClassLibraryTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIRcl_{Guid.NewGuid():N}"); + + public RazorClassLibraryTests() => Directory.CreateDirectory(_dir); + + public void Dispose() => Directory.Delete(_dir, true); + + [Fact] + public void RazorClassLibrary_IsServedUnderItsName() + { + var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor"); + + Assert.Equal("_content/MyApp.UI", InitService.RazorClassLibraryAssetBase(project)); + } + + [Theory] + [InlineData("Acme.Ui", "_content/Acme.Ui")] + [InlineData("Acme.Components", "_content/Acme.Components")] + [InlineData("/ui", "ui")] + public void RazorClassLibrary_HonoursItsAssetName(string property, string expected) + { + var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor", property); + + Assert.Equal(expected, InitService.RazorClassLibraryAssetBase(project)); + } + + [Fact] + public void WebAppsAreNotLibraries() + { + Assert.Null(InitService.RazorClassLibraryAssetBase(Project("MyApp.Web", "Microsoft.NET.Sdk.Web"))); + + var razorSdkWithProgram = Project("Hosted", "Microsoft.NET.Sdk.Razor"); + File.WriteAllText(Path.Combine(Path.GetDirectoryName(razorSdkWithProgram)!, "Program.cs"), ""); + Assert.Null(InitService.RazorClassLibraryAssetBase(razorSdkWithProgram)); + } + + [Fact] + public void HostSteps_UseTheLibrarysAssetPath() + { + var steps = InitService.RazorClassLibraryHostSteps("_content/MyApp.UI"); + + Assert.Contains(steps, s => s.Contains("_content/MyApp.UI/app.css") && s.Contains("_content/MyApp.UI/shellui.js")); + Assert.Contains(steps, s => s.Contains("@source")); + Assert.Contains(steps, s => s.Contains("prerender: false")); + Assert.Contains(steps, s => s.Contains(InitService.RazorClassLibraryGuide)); + } + + [Fact] + public void Guide_ShowsTheThemeScriptInitWrites() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (!File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent!; + var guide = File.ReadAllText(Path.Combine(root.FullName, "docs", "razor-class-library.md")).Replace("\r\n", "\n"); + var appRazor = InitService.RewriteAppRazor("\n").Replace("\r\n", "\n"); + + var start = appRazor.IndexOf("// ShellUI theme bootstrap", StringComparison.Ordinal); + var script = appRazor[start..appRazor.IndexOf("", start, StringComparison.Ordinal)]; + Assert.Contains(Unindent(script), Unindent(guide)); + } + + private static string Unindent(string text) => + string.Join("\n", text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)); + + private string Project(string name, string sdk, string properties = "") + { + var dir = Path.Combine(_dir, name); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, name + ".csproj"); + File.WriteAllText(path, $@"{properties}"); + return path; + } +} diff --git a/ShellUI.Tests/ShellCnTests.cs b/ShellUI.Tests/ShellCnTests.cs new file mode 100644 index 0000000..e62f1de --- /dev/null +++ b/ShellUI.Tests/ShellCnTests.cs @@ -0,0 +1,65 @@ +using ShellUI.Components; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class ShellCnTests +{ + [Theory] + [InlineData("max-w-md p-4", "max-w-lg", "p-4 max-w-lg")] + [InlineData("px-4 py-2", "px-6", "py-2 px-6")] + [InlineData("px-2", "p-4", "p-4")] + [InlineData("p-4", "px-2", "p-4 px-2")] + [InlineData("bg-primary text-primary-foreground", "bg-destructive", "text-primary-foreground bg-destructive")] + [InlineData("text-sm text-muted-foreground", "text-lg", "text-muted-foreground text-lg")] + [InlineData("text-sm text-muted-foreground", "text-red-500", "text-sm text-red-500")] + [InlineData("border border-input", "border-2", "border-input border-2")] + [InlineData("border border-input", "border-destructive", "border border-destructive")] + [InlineData("rounded-md", "rounded-none", "rounded-none")] + [InlineData("rounded-t-md", "rounded-lg", "rounded-lg")] + [InlineData("flex items-center", "hidden", "items-center hidden")] + [InlineData("hover:bg-accent", "hover:bg-muted", "hover:bg-muted")] + [InlineData("md:hover:bg-accent", "hover:md:bg-muted", "hover:md:bg-muted")] + [InlineData("data-[state=open]:bg-accent", "data-[state=open]:bg-muted", "data-[state=open]:bg-muted")] + [InlineData("mt-2", "-mt-4", "-mt-4")] + [InlineData("w-10 h-10", "size-8", "size-8")] + [InlineData("shadow-sm", "shadow-lg", "shadow-lg")] + [InlineData("ring-2 ring-ring", "ring-0", "ring-ring ring-0")] + [InlineData("font-medium font-sans", "font-semibold", "font-sans font-semibold")] + [InlineData("text-sm", "text-[13px]", "text-[13px]")] + [InlineData("text-foreground", "text-[#ff0000]", "text-[#ff0000]")] + [InlineData("[--sidebar-width:16rem]", "[--sidebar-width:20rem]", "[--sidebar-width:20rem]")] + public void LaterClassWins(string defaults, string overrides, string expected) => + Assert.Equal(expected, Shell.Cn(defaults, overrides)); + + [Theory] + [InlineData("p-4 md:p-6")] + [InlineData("bg-primary hover:bg-primary/90")] + [InlineData("ring-2 ring-ring ring-offset-2 ring-offset-background")] + [InlineData("border-b border-border")] + [InlineData("text-sm leading-none")] + [InlineData("shadow-sm shadow-black/5")] + [InlineData("max-w-lg max-w-lg!")] + [InlineData("translate-x-1/2 -translate-y-1/2")] + [InlineData("group peer custom-class another-custom")] + [InlineData("flex flex-col gap-2 overflow-hidden overflow-y-auto")] + public void DifferentPropertiesOrVariants_AreKept(string classes) => + Assert.Equal(classes, Shell.Cn(classes)); + + [Fact] + public void SkipsEmptyParts() => + Assert.Equal("a b", Shell.Cn(null, "a", "", " ", "b")); + + [Fact] + public void CliTemplate_IsThePackageHelper() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (!File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent!; + var package = File.ReadAllText(Path.Combine(root.FullName, "src", "ShellUI.Components", "Shell.cs")).Replace("\r\n", "\n"); + var template = ComponentRegistry.GetComponentContent("shell")! + .Replace("YourProjectNamespace.Components.UI", "ShellUI.Components").Replace("\r\n", "\n"); + + Assert.Equal(package, template); + } +} diff --git a/ShellUI.Tests/TailwindDownloaderTests.cs b/ShellUI.Tests/TailwindDownloaderTests.cs new file mode 100644 index 0000000..a8f5578 --- /dev/null +++ b/ShellUI.Tests/TailwindDownloaderTests.cs @@ -0,0 +1,283 @@ +using System.Net; +using System.Net.Http.Headers; +using ShellUI.CLI.Services; +using Xunit; + +namespace ShellUI.Tests; + +public class TailwindDownloaderTests : IDisposable +{ + private const string Url = "https://example.test/tailwindcss"; + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUITailwind_{Guid.NewGuid():N}"); + private readonly byte[] _binary = Enumerable.Range(0, 300_000).Select(i => (byte)(i * 7)).ToArray(); + + private readonly string? _previousCache = Environment.GetEnvironmentVariable("SHELLUI_CACHE_DIR"); + + private string Destination => Path.Combine(_dir, "tailwindcss-test"); + + // Tests in a class run one at a time, so pointing the shared cache at a temp folder here can't affect the others. + public TailwindDownloaderTests() => Environment.SetEnvironmentVariable("SHELLUI_CACHE_DIR", Path.Combine(_dir, "cache")); + + public void Dispose() + { + Environment.SetEnvironmentVariable("SHELLUI_CACHE_DIR", _previousCache); + if (Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + [Fact] + public async Task Downloads_TheWholeFile() + { + var server = new FakeServer((_, _) => Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.False(File.Exists(Destination + ".partial")); + } + + [Fact] + public async Task ResumesWithRange_AfterTheConnectionDrops() + { + var server = new FakeServer((request, attempt) => attempt == 1 + ? Full(_binary, new FailingStream(_binary[..100_000])) + : Partial(_binary, request.Headers.Range!.Ranges.Single().From!.Value)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.Equal(100_000, server.Requests[1].Headers.Range!.Ranges.Single().From); + } + + [Fact] + public async Task RetriesAShortDownload_InsteadOfKeepingIt() + { + var server = new FakeServer((request, attempt) => attempt == 1 + ? Full(_binary, new MemoryStream(_binary[..50_000])) + : Partial(_binary, request.Headers.Range!.Ranges.Single().From!.Value)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + Assert.Equal(2, server.Requests.Count); + } + + [Fact] + public async Task GivesUpOnAStall_AndRetries() + { + var server = new FakeServer((_, attempt) => attempt == 1 + ? Full(_binary, new StallingStream(_binary[..1000])) + : Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, + stallTimeout: TimeSpan.FromMilliseconds(300), retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + } + + [Fact] + public async Task StartsOver_WhenTheServerIgnoresRange() + { + var server = new FakeServer((_, attempt) => attempt == 1 + ? Full(_binary, new FailingStream(_binary[..100_000])) + : Full(_binary)); + + await TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero); + + Assert.Equal(_binary, File.ReadAllBytes(Destination)); + } + + [Fact] + public async Task NotFound_FailsAtOnce_AndLeavesNoFile() + { + var server = new FakeServer((_, _) => new HttpResponseMessage(HttpStatusCode.NotFound)); + + await Assert.ThrowsAsync(() => + TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero)); + + Assert.Single(server.Requests); + Assert.False(File.Exists(Destination)); + } + + [Fact] + public async Task KeepsFailing_ThrowsAfterTheLastAttempt_AndLeavesNoFile() + { + var server = new FakeServer((_, _) => Full(_binary, new FailingStream(_binary[..10]))); + + await Assert.ThrowsAsync(() => + TailwindDownloader.DownloadAsync(Url, Destination, handler: server, retryDelay: TimeSpan.Zero)); + + Assert.Equal(4, server.Requests.Count); + Assert.False(File.Exists(Destination)); + } + + [Fact] + public void CachePath_MatchesThePrecompiledCssScript() + { + var name = Path.GetFileName(TailwindDownloader.CachedPath()); + + Assert.StartsWith($"tailwindcss-{ShellUI.Core.TailwindConstants.Version}-", name); + } + + [Fact] + public void BuildTargets_UseTheSameCacheName() + { + var targets = InitService.GetTargetsFileContent("standalone"); + + Assert.Contains($"{ShellUI.Core.TailwindConstants.Version}", targets); + Assert.Contains("tailwindcss-$(TailwindVersion)-$(TailwindPlatform)", targets); + Assert.Contains("$(TailwindCached)", targets); + Assert.DoesNotContain(" n.Contains("Updated:")); + Assert.Equal(InitService.GetTargetsFileContent("standalone"), File.ReadAllText(path)); + Assert.Equal("*\n", File.ReadAllText(Path.Combine(_dir, ".shellui", ".gitignore"))); + } + + [Fact] + public void Update_LeavesAnEditedTargetsFile_AndSaysSo() + { + var edited = Fixture("ShellUI-0.4.0-alpha.1.targets.txt").Replace("--minify", "--minify --optimize"); + var path = WriteTargets(edited); + + var notes = InitService.RefreshTailwindSetup(_dir); + + Assert.Contains(notes, n => n.Contains("local changes")); + Assert.Equal(edited, File.ReadAllText(path)); + } + + [Fact] + public void Update_LeavesCurrentAndNpmTargetsAlone() + { + WriteTargets(InitService.GetTargetsFileContent("standalone")); + Assert.Empty(InitService.RefreshTailwindSetup(_dir)); + + var npm = WriteTargets(InitService.GetTargetsFileContent("npm")); + Assert.Empty(InitService.RefreshTailwindSetup(_dir)); + Assert.Equal(InitService.GetTargetsFileContent("npm"), File.ReadAllText(npm)); + } + + [Fact] + public void Update_DropsTheProjectCopy_WhenItIsTheSharedOne() + { + WriteTargets(Fixture("ShellUI-0.4.0-alpha.1.targets.txt")); + var cached = TailwindDownloader.CachedPath(); + Directory.CreateDirectory(Path.GetDirectoryName(cached)!); + File.WriteAllBytes(cached, _binary); + var projectCopy = TailwindDownloader.GetTailwindPath(_dir); + Directory.CreateDirectory(Path.GetDirectoryName(projectCopy)!); + File.WriteAllBytes(projectCopy, _binary); + + var notes = InitService.RefreshTailwindSetup(_dir); + + Assert.Contains(notes, n => n.Contains("Removed:")); + Assert.False(File.Exists(projectCopy)); + Assert.True(File.Exists(cached)); + } + + [Fact] + public void Update_KeepsAProjectCopyThatDiffersFromTheSharedOne() + { + WriteTargets(InitService.GetTargetsFileContent("standalone")); + var cached = TailwindDownloader.CachedPath(); + Directory.CreateDirectory(Path.GetDirectoryName(cached)!); + File.WriteAllBytes(cached, _binary); + var projectCopy = TailwindDownloader.GetTailwindPath(_dir); + Directory.CreateDirectory(Path.GetDirectoryName(projectCopy)!); + File.WriteAllBytes(projectCopy, _binary[..1000]); + + InitService.RefreshTailwindSetup(_dir); + + Assert.True(File.Exists(projectCopy)); + } + + [Fact] + public void ShellUiFolder_IgnoresItself_AndLeavesAnExistingIgnoreAlone() + { + TailwindDownloader.IgnoreShellUiFolder(_dir); + var ignore = Path.Combine(_dir, ".shellui", ".gitignore"); + Assert.Equal("*\n", File.ReadAllText(ignore)); + + File.WriteAllText(ignore, "bin/\n"); + TailwindDownloader.IgnoreShellUiFolder(_dir); + Assert.Equal("bin/\n", File.ReadAllText(ignore)); + } + + private string WriteTargets(string content) + { + var path = Path.Combine(_dir, "Build", "ShellUI.targets"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private static string Fixture(string name, [System.Runtime.CompilerServices.CallerFilePath] string thisFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(thisFile)!, "Fixtures", "Targets", name)); + + private static HttpResponseMessage Full(byte[] body, Stream? stream = null) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(stream ?? new MemoryStream(body)) }; + response.Content.Headers.ContentLength = body.Length; + return response; + } + + private static HttpResponseMessage Partial(byte[] body, long from) + { + var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(body[(int)from..]) }; + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, body.Length - 1, body.Length); + return response; + } + + private sealed class FakeServer : HttpMessageHandler + { + private readonly Func _respond; + public List Requests { get; } = new(); + + public FakeServer(Func respond) => _respond = respond; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_respond(request, Requests.Count)); + } + } + + // Sends its bytes, then fails like a dropped connection. + private sealed class FailingStream(byte[] data) : MemoryStream(data) + { + public override int Read(byte[] buffer, int offset, int count) + { + var read = base.Read(buffer, offset, count); + return read > 0 ? read : throw new IOException("connection reset"); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await base.ReadAsync(buffer, cancellationToken); + return read > 0 ? read : throw new IOException("connection reset"); + } + } + + // Sends its bytes, then never sends anything again. + private sealed class StallingStream(byte[] data) : MemoryStream(data) + { + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await base.ReadAsync(buffer, cancellationToken); + if (read > 0) return read; + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } +} diff --git a/ShellUI.Tests/UpdateTests.cs b/ShellUI.Tests/UpdateTests.cs new file mode 100644 index 0000000..07c846a --- /dev/null +++ b/ShellUI.Tests/UpdateTests.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.IO; +using ShellUI.CLI.Services; +using ShellUI.Core.Models; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class UpdateDependencyTests +{ + [Fact] + public void MissingDependencies_FindsDependenciesAddedSinceInstall() + { + // An older install of tabs and dialog, before tab-models and the dialog parts were dependencies. + var installed = new List { "tabs", "tabs-list", "tabs-trigger", "tabs-content", "dialog", "dialog-trigger" }; + + var missing = ComponentManager.MissingDependencies(new[] { "tabs", "dialog" }, installed); + + Assert.Contains("tab-models", missing); + Assert.Contains("dialog-content", missing); + Assert.Contains("dialog-close", missing); + Assert.DoesNotContain("tabs-list", missing); + Assert.Equal(missing.Count, new HashSet(missing).Count); + } + + [Fact] + public void MissingDependencies_IsEmptyWhenEverythingIsInstalled() + { + var tabs = ComponentRegistry.GetMetadata("tabs")!; + var installed = new List(tabs.Dependencies) { "tabs" }; + + Assert.Empty(ComponentManager.MissingDependencies(new[] { "tabs" }, installed)); + } +} + +public class UpdateLocalChangesTests +{ + private const string Template = "
@ChildContent
\n"; + + private static InstalledComponent Entry(string name, string? hash) => new() { Name = name, Version = "0.4.0", Hash = hash }; + + [Fact] + public void FileMatchingTheRecordedHash_HasNoLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template, Template)); + } + + [Fact] + public void EditedFile_HasLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.True(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template + "", Template)); + } + + [Fact] + public void LineEndingChangesAreNotLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template.Replace("\n", "\r\n"), Template)); + } + + [Theory] + [InlineData("app-sidebar")] + [InlineData("dashboard-02")] + public void OlderInstall_EditableBlockThatDiffersFromTheTemplate_HasLocalChanges(string name) + { + Assert.True(ComponentManager.HasLocalChanges(Entry(name, null), ComponentRegistry.GetMetadata(name)!, "edited", Template)); + } + + [Fact] + public void OlderInstall_EditableBlockMatchingTheTemplate_HasNoLocalChanges() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("app-sidebar", null), ComponentRegistry.GetMetadata("app-sidebar")!, Template, Template)); + } + + [Fact] + public void OlderInstall_OrdinaryComponentIsUpdatedAsBefore() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("button", null), ComponentRegistry.GetMetadata("button")!, "edited", Template)); + } +} diff --git a/VERSIONING_STRATEGY.md b/VERSIONING_STRATEGY.md index cea7c31..e24b6c8 100644 --- a/VERSIONING_STRATEGY.md +++ b/VERSIONING_STRATEGY.md @@ -6,7 +6,7 @@ ShellUI uses one centralized version for the source templates, CLI tool, and pac | Scope | Value | |---|---| -| Version | `0.3.4` | +| Version | `0.3.5` | | Target framework | .NET 10 | | Tailwind version | `4.3.2` | @@ -18,15 +18,15 @@ The root `Directory.Build.props` supplies the version: ```xml - 0.3.4 + 0.3.5 ``` `Directory.Build.props` composes the package and assembly metadata: -- `Version` is `0.3.4`; with a suffix such as `rc.1` it becomes `0.3.4-rc.1`. -- `AssemblyVersion` and `FileVersion` use the numeric base `0.3.4`. +- `Version` is `0.3.5`; with a suffix such as `rc.1` it becomes `0.3.5-rc.1`. +- `AssemblyVersion` and `FileVersion` use the numeric base `0.3.5`. - `InformationalVersion` includes the prerelease suffix. - Component metadata reads the centralized properties when running from the repository. `VersionHelper` uses assembly metadata as a fallback when its repository search does not find the solution file. @@ -57,7 +57,7 @@ The CLI writes the computed version into `shellui.json` for each installed entry "InstalledComponents": [ { "Name": "button", - "Version": "0.3.4", + "Version": "0.3.5", "InstalledAt": "2026-01-01T00:00:00Z", "IsCustomized": false } @@ -113,7 +113,7 @@ Release tags use the `v` prefix, for example `v0.3.0-rc.2`. Pushing a `v*` tag r 3. Merge, then on an up-to-date `main` run `pwsh ./prepare-release.ps1 -Version -Suffix -DryRun` (builds, tests and checks the notes section). 4. Tag the merged commit and push the tag: `git tag v` then `git push origin v`. -The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging. +The release workflow publishes with NuGet Trusted Publishing; the one-time GitHub and nuget.org setup, and the full release checklist, are in [docs/RELEASING.md](docs/RELEASING.md). The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging. The verification commands are: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03a1e51..f73ffcf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,7 +80,7 @@ The implemented command tree is: | `shellui add ` | Install one or more direct targets and recursively install their registry dependencies. | | `shellui list` | List direct targets, with installed/available filtering. | | `shellui remove ` | Remove selected installed source files and update `shellui.json`. | -| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`. | +| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`; skips files changed since install unless `--force`, and installs new dependencies. | | `shellui theme init ` | Initialize a project and apply a fetched theme in one operation. | | `shellui theme apply ` | Apply a theme to `wwwroot/input.css` or emit a standalone override. | | `shellui theme update` | Re-fetch the theme recorded in `shellui.theme.lock`. | diff --git a/docs/CLI_INSTALLATION.md b/docs/CLI_INSTALLATION.md index d48ff2d..777cfc2 100644 --- a/docs/CLI_INSTALLATION.md +++ b/docs/CLI_INSTALLATION.md @@ -4,7 +4,7 @@ | Context | Version | |---|---| -| Latest stable (recommended) | `0.3.4`, needs the .NET 10 runtime | +| Latest stable (recommended) | `0.3.5`, needs the .NET 10 runtime | | Previous stable | `0.2.1` | | Tailwind | `4.3.2` | @@ -43,7 +43,7 @@ The manifest is `.config/dotnet-tools.json` and uses the installed package versi "isRoot": true, "tools": { "shellui.cli": { - "version": "0.3.4", + "version": "0.3.5", "commands": ["shellui"] } } diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index c633d29..6942145 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -1,6 +1,6 @@ # ShellUI CLI Syntax -This reference covers CLI `0.3.4`: it runs on .NET 10, sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has and 76 direct component targets. Older versions, such as `0.2.1`, lack some of these commands. +This reference covers CLI `0.3.5`: it runs on .NET 10, sets up Blazor projects on .NET 8, 9 and 10, uses Tailwind CSS `4.3.2`, and has 76 direct component targets. Older versions, such as `0.2.1`, lack some of these commands. ## Command prefix @@ -20,7 +20,7 @@ shellui init [--force] [--style