Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ShellUIBinPath Condition="'$(ShellUIBinPath)' == ''">$(MSBuildProjectDirectory)\.shellui\bin</ShellUIBinPath>
<TailwindExecutable Condition="'$(OS)' == 'Windows_NT'">$(ShellUIBinPath)\tailwindcss.exe</TailwindExecutable>
<TailwindExecutable Condition="'$(OS)' != 'Windows_NT'">$(ShellUIBinPath)/tailwindcss</TailwindExecutable>
<TailwindInputCss Condition="'$(TailwindInputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\input.css</TailwindInputCss>
<TailwindOutputCss Condition="'$(TailwindOutputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
<TailwindMinify Condition="'$(Configuration)' != 'Release'"></TailwindMinify>
</PropertyGroup>

<Target Name="BuildTailwindCSS" BeforeTargets="BeforeBuild" Condition="Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Message Importance="high" Text="Building Tailwind CSS..." />
<Exec Command="&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)" />
<Message Importance="high" Text="Tailwind CSS built successfully!" />
</Target>

<Target Name="CleanTailwindCSS" AfterTargets="Clean" Condition="Exists('$(TailwindOutputCss)')">
<Message Importance="high" Text="Cleaning Tailwind CSS output..." />
<Delete Files="$(TailwindOutputCss)" />
</Target>
</Project>
38 changes: 38 additions & 0 deletions ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ShellUIBinPath Condition="'$(ShellUIBinPath)' == ''">$(MSBuildProjectDirectory)\.shellui\bin</ShellUIBinPath>
<TailwindExecutable Condition="'$(OS)' == 'Windows_NT'">$(ShellUIBinPath)\tailwindcss.exe</TailwindExecutable>
<TailwindExecutable Condition="'$(OS)' != 'Windows_NT'">$(ShellUIBinPath)/tailwindcss</TailwindExecutable>
<TailwindInputCss Condition="'$(TailwindInputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\input.css</TailwindInputCss>
<TailwindOutputCss Condition="'$(TailwindOutputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
<TailwindMinify Condition="'$(Configuration)' != 'Release'"></TailwindMinify>
<TailwindTag>v4.3.2</TailwindTag>
<TailwindArch>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant())</TailwindArch>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Windows'))">windows-$(TailwindArch).exe</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Linux'))">linux-$(TailwindArch)</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('OSX'))">macos-$(TailwindArch)</TailwindPlatform>
</PropertyGroup>

<Target Name="DownloadTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)') AND '$(TailwindPlatform)' != ''">
<Message Importance="high" Text="Downloading the Tailwind CLI $(TailwindTag)..." />
<DownloadFile SourceUrl="https://github.com/tailwindlabs/tailwindcss/releases/download/$(TailwindTag)/tailwindcss-$(TailwindPlatform)" DestinationFolder="$(ShellUIBinPath)" DestinationFileName="$([System.IO.Path]::GetFileName('$(TailwindExecutable)'))" ContinueOnError="true" />
<Exec Command="chmod +x &quot;$(TailwindExecutable)&quot;" Condition="'$(OS)' != 'Windows_NT' AND Exists('$(TailwindExecutable)')" />
</Target>

<Target Name="WarnMissingTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Warning Text="ShellUI: the Tailwind CLI is missing from $(ShellUIBinPath) and could not be downloaded, so wwwroot/app.css was not rebuilt." />
</Target>

<Target Name="BuildTailwindCSS" BeforeTargets="BeforeBuild" Condition="Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Message Importance="high" Text="Building Tailwind CSS..." />
<Exec Command="&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)" />
<Message Importance="high" Text="Tailwind CSS built successfully!" />
</Target>

<Target Name="CleanTailwindCSS" AfterTargets="Clean" Condition="Exists('$(TailwindOutputCss)')">
<Message Importance="high" Text="Cleaning Tailwind CSS output..." />
<Delete Files="$(TailwindOutputCss)" />
</Target>
</Project>
283 changes: 283 additions & 0 deletions ShellUI.Tests/TailwindDownloaderTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOException>(() =>
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<IOException>(() =>
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($"<TailwindVersion>{ShellUI.Core.TailwindConstants.Version}</TailwindVersion>", targets);
Assert.Contains("tailwindcss-$(TailwindVersion)-$(TailwindPlatform)", targets);
Assert.Contains("<TailwindExecutable Condition=\"'$(TailwindExecutable)' == ''\">$(TailwindCached)</TailwindExecutable>", targets);
Assert.DoesNotContain("<Copy ", targets);
Assert.True(targets.IndexOf("DownloadTailwindCLI") < targets.IndexOf("WarnMissingTailwindCLI")
&& targets.IndexOf("WarnMissingTailwindCLI") < targets.IndexOf("BuildTailwindCSS"));
}

[Theory]
[InlineData("ShellUI-0.3.4.targets.txt")]
[InlineData("ShellUI-0.4.0-alpha.1.targets.txt")]
public void Update_ReplacesATargetsFileAnEarlierCliWrote(string fixture)
{
var path = WriteTargets(Fixture(fixture));

var notes = InitService.RefreshTailwindSetup(_dir);

Assert.Contains(notes, n => 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<HttpRequestMessage, int, HttpResponseMessage> _respond;
public List<HttpRequestMessage> Requests { get; } = new();

public FakeServer(Func<HttpRequestMessage, int, HttpResponseMessage> respond) => _respond = respond;

protected override Task<HttpResponseMessage> 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<int> ReadAsync(Memory<byte> 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<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await base.ReadAsync(buffer, cancellationToken);
if (read > 0) return read;
await Task.Delay(Timeout.Infinite, cancellationToken);
return 0;
}
}
}
9 changes: 5 additions & 4 deletions docs/CLI_SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ In apps created with `--auth Individual`, `init` renders the `/Account` pages st

With `--dashboard`, `init` then runs `shellui add dashboard-02` (or `dashboard-01`), including the layout wiring described under [Dashboard layouts](#dashboard-layouts).

Standalone mode stores the Tailwind executable in `.shellui/bin/`. npm mode installs `tailwindcss@^4.3.2` and `@tailwindcss/cli@^4.3.2` and requires Node.js and npm. The current CLI invokes npm through `cmd`; use standalone mode on non-Windows systems or run npm manually.
Standalone mode downloads the Tailwind executable once per machine into `~/.shellui/bin` (set `SHELLUI_CACHE_DIR` to move it), and every project builds with that copy; a copy in a project's `.shellui/bin/` takes precedence. If the download fails, `init` still completes and the build retries it. npm mode installs `tailwindcss@^4.3.2` and `@tailwindcss/cli@^4.3.2` and requires Node.js and npm. The current CLI invokes npm through `cmd`; use standalone mode on non-Windows systems or run npm manually.

## `add`

Expand Down Expand Up @@ -206,8 +206,9 @@ Run it from the project root after a theme has been applied.
| `wwwroot/input.css` | Tailwind v4 input and theme variables |
| `wwwroot/app.css` | Generated Tailwind output |
| `tailwind.config.js` | Project content globs and dark-mode setting |
| `.shellui/bin/tailwindcss` | Standalone CLI on macOS/Linux |
| `.shellui/bin/tailwindcss.exe` | Standalone CLI on Windows |
| `~/.shellui/bin/tailwindcss-<version>-<platform>` | Standalone CLI, shared by every project on the machine |
| `.shellui/.gitignore` | Keeps `.shellui/` out of git |
| `.shellui/bin/tailwindcss(.exe)` | Optional per-project CLI, used instead of the shared one |
| `Build/ShellUI.targets` | MSBuild integration |
| `shellui.json` | ShellUI project configuration |
| `shellui.theme.lock` | Theme source and integrity record |
Expand Down Expand Up @@ -288,7 +289,7 @@ shellui add button,card
- Run `shellui list` to verify a target name.
- Confirm `wwwroot/input.css` exists and contains `@import "tailwindcss";`.
- Run `dotnet build` to invoke the generated Tailwind build target.
- In standalone mode, check `.shellui/bin/`; in npm mode, run `npm install`.
- In standalone mode, check `~/.shellui/bin`; in npm mode, run `npm install`.

## Related documentation

Expand Down
4 changes: 2 additions & 2 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Not for standalone mode:
shellui init --yes --tailwind standalone
```

Standalone mode downloads the Tailwind `4.3.2` CLI into `.shellui/bin/` and integrates it with the generated build target.
Standalone mode downloads the Tailwind `4.3.2` CLI once per machine (into `~/.shellui/bin`, or `SHELLUI_CACHE_DIR`) and the generated build target runs it from there. Projects hold no copy of the binary, and later projects set up offline. The binary is a build tool: it never ships with your app, which only gets the generated `app.css`.

npm mode is also supported. It requires Node.js and npm:

Expand Down Expand Up @@ -227,7 +227,7 @@ Check that `wwwroot/input.css` exists and starts with the Tailwind v4 import, th
dotnet build
```

For standalone mode, check `.shellui/bin/`. For npm mode, run `npm install`.
For standalone mode, check `~/.shellui/bin`. For npm mode, run `npm install`.

## Package updates and licensing

Expand Down
Loading
Loading