>()
+ );
+
+ private static CivitModel Model(int id, params CivitModelVersion[] versions) =>
+ new()
+ {
+ Id = id,
+ Name = $"Model {id}",
+ Type = CivitModelType.Checkpoint,
+ ModelVersions = versions.ToList(),
+ };
+
+ private static CivitModelVersion Version(int id, string baseModel) =>
+ new()
+ {
+ Id = id,
+ Name = $"Version {id}",
+ BaseModel = baseModel,
+ PublishedAt = DateTimeOffset.UtcNow.AddDays(-id),
+ Images = [],
+ Files =
+ [
+ new CivitFile
+ {
+ Id = id,
+ Type = CivitFileType.TrainingData,
+ Hashes = new CivitFileHashes(),
+ },
+ ],
+ };
+
+ private static Task LoadDetails(CivitDetailsPageViewModel vm) =>
+ (Task)Invoke(vm, "OnInitialLoadedAsync")!;
+
+ private static object? Invoke(object target, string method, params object?[] arguments) =>
+ target
+ .GetType()
+ .GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(target, arguments);
+
+ private sealed class ImmediateSynchronizationContext : SynchronizationContext
+ {
+ public override void Post(SendOrPostCallback callback, object? state) => callback(state);
+ }
+}
diff --git a/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs b/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs
new file mode 100644
index 000000000..67b8cf826
--- /dev/null
+++ b/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs
@@ -0,0 +1,37 @@
+using StabilityMatrix.Avalonia.Controls;
+
+namespace StabilityMatrix.Tests.Avalonia;
+
+[TestClass]
+public class MarkdownViewerTests
+{
+ [TestMethod]
+ public void EncodeAstralCharacters_Emoji_BecomesOneCharacterReference()
+ {
+ var html = MarkdownViewer.EncodeAstralCharacters("so sad \U0001F97A its perfect
");
+
+ Assert.AreEqual("so sad 🥺 its perfect
", html);
+ }
+
+ [TestMethod]
+ public void EncodeAstralCharacters_ZwjSequence_KeepsJoinersBetweenReferences()
+ {
+ var html = MarkdownViewer.EncodeAstralCharacters("\U0001F43A\u200D\U0001F525");
+
+ Assert.AreEqual("🐺\u200D🔥", html);
+ }
+
+ [TestMethod]
+ public void EncodeAstralCharacters_BmpOnly_ReturnsSameInstance()
+ {
+ const string html = "héllo 狼 ;3
";
+
+ Assert.AreSame(html, MarkdownViewer.EncodeAstralCharacters(html));
+ }
+
+ [TestMethod]
+ public void EncodeAstralCharacters_LoneSurrogate_BecomesReplacementCharacter()
+ {
+ Assert.AreEqual("a\uFFFDb", MarkdownViewer.EncodeAstralCharacters("a\uD83Eb"));
+ }
+}
diff --git a/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs b/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs
new file mode 100644
index 000000000..46ac72867
--- /dev/null
+++ b/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs
@@ -0,0 +1,15 @@
+using StabilityMatrix.Avalonia.Models.BananaVision;
+
+namespace StabilityMatrix.Tests.Avalonia;
+
+[TestClass]
+public class ThinkingMessageTests
+{
+ [TestMethod]
+ public void ThinkingPreview_CutInsideEmoji_DoesNotSplitTheSurrogatePair()
+ {
+ var thinking = new ThinkingMessage(new string('a', 99) + "\U0001F97A" + new string('b', 20));
+
+ Assert.AreEqual(new string('a', 99) + "...", thinking.ThinkingPreview);
+ }
+}
diff --git a/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs b/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs
index bae974d00..bee52e36e 100644
--- a/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs
+++ b/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs
@@ -9,6 +9,46 @@ namespace StabilityMatrix.Tests.Core;
[TestClass]
public class CivArchiveApiClientTests
{
+ [TestMethod]
+ [DataRow(
+ "/models/2612109?modelVersionId=2932986",
+ "https://civarchive.com/models/2612109?modelVersionId=2932986"
+ )]
+ [DataRow(
+ "/seaart/models/07a9c14c35691c1a7de156a462355bd4/versions/7b9fc01641ec058d815d8e25aaa950ad",
+ "https://civarchive.com/seaart/models/07a9c14c35691c1a7de156a462355bd4/versions/7b9fc01641ec058d815d8e25aaa950ad"
+ )]
+ [DataRow("models/123?modelVersionId=456", "https://civarchive.com/models/123?modelVersionId=456")]
+ [DataRow(
+ "https://huggingface.co/example/model/resolve/main/model.safetensors?download=true",
+ "https://huggingface.co/example/model/resolve/main/model.safetensors?download=true"
+ )]
+ [DataRow("http://example.org/model?download=true", "http://example.org/model?download=true")]
+ [DataRow("//example.org/model?download=true", "https://example.org/model?download=true")]
+ [DataRow("/models/a%20b?name=a%2Fb#files", "https://civarchive.com/models/a%20b?name=a%2Fb#files")]
+ [DataRow("", "https://civarchive.com/")]
+ [DataRow(" ", "https://civarchive.com/")]
+ [DataRow("file:///tmp/model", "file:///tmp/model")]
+ [DataRow("mailto:test@example.org", "mailto:test@example.org")]
+ public void GetAbsoluteUri_ResolvesRoutesAndPreservesExplicitUris(string input, string expected)
+ {
+ var client = CreateClient(
+ new RecordingHandler((_, _) => throw new AssertFailedException("Unexpected HTTP request"))
+ );
+
+ Assert.AreEqual(expected, client.GetAbsoluteUri(input).AbsoluteUri);
+ }
+
+ [TestMethod]
+ public void GetAbsoluteUri_RejectsMalformedAbsoluteUri()
+ {
+ var client = CreateClient(
+ new RecordingHandler((_, _) => throw new AssertFailedException("Unexpected HTTP request"))
+ );
+
+ Assert.ThrowsException(() => client.GetAbsoluteUri("http://[invalid"));
+ }
+
[TestMethod]
public void BuildSearchDataPath_UsesDefaultFilterValues()
{
diff --git a/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs b/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs
new file mode 100644
index 000000000..f30cdbfdc
--- /dev/null
+++ b/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs
@@ -0,0 +1,47 @@
+using Semver;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Api.Comfy.Nodes;
+using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes;
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Models.Packages.Extensions;
+
+namespace StabilityMatrix.Tests.Core;
+
+[TestClass]
+public class ControlNetExtensionRequirementTests
+{
+ [TestMethod]
+ [DataRow("v0.2.0", false)]
+ [DataRow("v0.4.2", false)]
+ [DataRow("v0.5.2", true)]
+ [DataRow("v0.5.3", true)]
+ public void PreprocessorWorkflow_RequiresPublishedCompatibilityFixes(string installedTag, bool satisfies)
+ {
+ var nodes = new NodeDictionary();
+ nodes.AddTypedNode(
+ new ComfyNodeBuilder.AIOPreprocessor
+ {
+ Name = "ControlNet_Preprocessor",
+ Image = new ImageNodeConnection { Data = ["LoadImage", 0] },
+ Preprocessor = "CannyEdgePreprocessor",
+ }
+ );
+
+ var specifier = nodes.RequiredExtensions.Single();
+ var installed = new InstalledPackageExtension
+ {
+ Paths = [new DirectoryPath(Path.GetTempPath(), "ComfyUI-Inference-Core-Nodes")],
+ GitRepositoryUrl = "https://github.com/LykosAI/ComfyUI-Inference-Core-Nodes.git",
+ Version = new PackageExtensionVersion { Tag = installedTag },
+ };
+
+ var matched = ExtensionMatcher.FindInstalled(specifier, [installed]);
+ Assert.IsNotNull(
+ matched,
+ "An older version must be recognized as installed before checking its tag."
+ );
+ Assert.IsTrue(specifier.TryGetSemVersionRange(out var range));
+ Assert.IsTrue(SemVersion.TryParse(matched.Version!.Tag, SemVersionStyles.AllowV, out var version));
+ Assert.AreEqual(satisfies, range!.Contains(version!));
+ }
+}
diff --git a/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs b/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs
new file mode 100644
index 000000000..f4a95c431
--- /dev/null
+++ b/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs
@@ -0,0 +1,72 @@
+using StabilityMatrix.Core.Extensions;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.ReparsePoints;
+
+namespace StabilityMatrix.Tests.Core;
+
+[TestClass]
+public class DirectoryPathSizeTests
+{
+ private string tempRoot = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ tempRoot = Path.Combine(Path.GetTempPath(), $"sm-size-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempRoot);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ if (Directory.Exists(tempRoot))
+ new DirectoryPath(tempRoot).DeleteVerbose();
+ }
+
+ ///
+ /// A package using symlink sharing: models/checkpoints links to the shared models folder,
+ /// one level below the package root, beside the package's own files and a hidden .git.
+ ///
+ private (DirectoryPath Package, string SharedModel) CreateLinkedPackage()
+ {
+ var shared = Directory.CreateDirectory(Path.Combine(tempRoot, "Models", "StableDiffusion"));
+ var sharedModel = Path.Combine(shared.FullName, "model.safetensors");
+ File.WriteAllBytes(sharedModel, new byte[10_000]);
+
+ var package = Directory.CreateDirectory(Path.Combine(tempRoot, "Packages", "ComfyUI"));
+ File.WriteAllBytes(Path.Combine(package.FullName, "main.py"), new byte[100]);
+
+ var git = Directory.CreateDirectory(Path.Combine(package.FullName, ".git"));
+ git.Attributes |= FileAttributes.Hidden;
+ File.WriteAllBytes(Path.Combine(git.FullName, "pack"), new byte[20]);
+
+ var models = Directory.CreateDirectory(Path.Combine(package.FullName, "models"));
+ var link = Path.Combine(models.FullName, "checkpoints");
+ if (Compat.IsWindows)
+ Junction.Create(link, shared.FullName, false);
+ else
+ Directory.CreateSymbolicLink(link, shared.FullName);
+
+ return (new DirectoryPath(package.FullName), sharedModel);
+ }
+
+ [TestMethod]
+ public void GetSize_ExcludingLinks_SkipsNestedLinkTargets()
+ {
+ var (package, _) = CreateLinkedPackage();
+
+ Assert.AreEqual(120, package.GetSize(includeSymbolicLinks: false));
+ }
+
+ [TestMethod]
+ public void DeleteVerbose_RemovesNestedLinkButKeepsTarget()
+ {
+ var (package, sharedModel) = CreateLinkedPackage();
+
+ package.DeleteVerbose();
+
+ Assert.IsFalse(Directory.Exists(package.FullPath));
+ Assert.IsTrue(File.Exists(sharedModel));
+ }
+}
diff --git a/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs b/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs
new file mode 100644
index 000000000..7e0229291
--- /dev/null
+++ b/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs
@@ -0,0 +1,45 @@
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using StabilityMatrix.Core.Api;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Api.Gemini;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.Tests.Core;
+
+[TestClass]
+public class GeminiImageGenerationProviderTests
+{
+ [TestMethod]
+ public async Task GenerateAsync_CancelledByCaller_ThrowsInsteadOfReportingFailure()
+ {
+ using var cts = new CancellationTokenSource();
+ var geminiApi = Substitute.For();
+ geminiApi
+ .GenerateContentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any()
+ )
+ .Returns(_ =>
+ {
+ cts.Cancel();
+ return Task.FromException(
+ new TaskCanceledException("A task was canceled.")
+ );
+ });
+ var secretsManager = Substitute.For();
+ secretsManager.SafeLoadAsync().Returns(new Secrets { GeminiApiKey = "test-key" });
+ var provider = new Gemini3ProImageGenerationProvider(
+ Substitute.For>(),
+ geminiApi,
+ secretsManager
+ );
+
+ await Assert.ThrowsExceptionAsync(() =>
+ provider.GenerateAsync(new ImageGenerationRequest { TextPrompt = "a wolf" }, cts.Token)
+ );
+ }
+}
diff --git a/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs b/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs
new file mode 100644
index 000000000..a136d8510
--- /dev/null
+++ b/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs
@@ -0,0 +1,73 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using StabilityMatrix.Core.Database;
+using StabilityMatrix.Core.Models.Configs;
+using StabilityMatrix.Core.Models.Database;
+using StabilityMatrix.Core.Models.FileInterfaces;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.Tests.Core;
+
+[TestClass]
+public class ImageGenerationChatServiceTests
+{
+ [TestMethod]
+ public async Task RetryGenerationAsync_WithOldResponse_SendsOnlyMessagesBeforeThePrompt()
+ {
+ var settingsManager = Substitute.For();
+ settingsManager.LibraryDir.Returns(new DirectoryPath(Path.GetTempPath()));
+ using var database = new BananaVisionDbContext(
+ NullLogger.Instance,
+ settingsManager,
+ Options.Create(new DebugOptions { TempDatabase = true })
+ );
+
+ ImageGenerationRequest? sentRequest = null;
+ var provider = Substitute.For();
+ provider.ProviderId.Returns("test");
+ provider
+ .GenerateAsync(Arg.Do(r => sentRequest = r), Arg.Any())
+ .Returns(new ImageGenerationResponse { IsSuccess = true, TextResponse = "new reply" });
+
+ var service = new ImageGenerationChatService(
+ NullLogger.Instance,
+ database,
+ settingsManager,
+ [provider]
+ );
+
+ var conversation = await service.CreateConversationAsync("test");
+ var t0 = DateTime.UtcNow.AddMinutes(-10);
+ foreach (
+ var (role, text, minutes) in new[]
+ {
+ (MessageRole.User, "first prompt", 0),
+ (MessageRole.Assistant, "first reply", 1),
+ (MessageRole.User, "second prompt", 2),
+ (MessageRole.Assistant, "reply being regenerated", 3),
+ }
+ )
+ {
+ await database.Messages.InsertAsync(
+ new ImageGenerationMessage
+ {
+ ConversationId = conversation.Id,
+ Role = role,
+ TextContent = text,
+ Timestamp = t0.AddMinutes(minutes),
+ }
+ );
+ }
+
+ await service.RetryGenerationAsync(conversation.Id, "test");
+
+ Assert.IsNotNull(sentRequest);
+ Assert.AreEqual("second prompt", sentRequest.TextPrompt);
+ CollectionAssert.AreEqual(
+ new[] { "first prompt", "first reply" },
+ sentRequest.ConversationHistory!.Select(m => m.TextContent).ToArray()
+ );
+ }
+}
diff --git a/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs b/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs
new file mode 100644
index 000000000..5de9ea6d1
--- /dev/null
+++ b/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs
@@ -0,0 +1,285 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Text;
+using StabilityMatrix.Core.Helper;
+
+namespace StabilityMatrix.Tests.Core;
+
+[TestClass]
+public class TarGZipExtractorTests
+{
+ private string workspace = null!;
+ private string output = null!;
+ private string archive = null!;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ workspace = Path.Combine(Path.GetTempPath(), $"tar-extraction-{Guid.NewGuid():N}");
+ output = Path.Combine(workspace, "output with spaces");
+ archive = Path.Combine(workspace, "archive.tar.gz");
+ Directory.CreateDirectory(workspace);
+ }
+
+ [TestCleanup]
+ public void Cleanup() => Directory.Delete(workspace, recursive: true);
+
+ [TestMethod]
+ public async Task ExtractAsync_RegularFilesAndDirectories()
+ {
+ WriteArchive(
+ new PaxTarEntry(TarEntryType.Directory, "node/lib/"),
+ FileEntry("node/lib/file with spaces", "contents")
+ );
+
+ await TarGZipExtractor.ExtractAsync(archive, output);
+
+ Assert.AreEqual("contents", File.ReadAllText(Path.Combine(output, "node/lib/file with spaces")));
+ }
+
+ [DataTestMethod]
+ [DataRow("node-v20.19.3-linux-x64")]
+ [DataRow("node-v20.19.3-darwin-arm64")]
+ public async Task ExtractAsync_NodeLinksBeforeTargetsAndExecutableMode(string folder)
+ {
+ RequireSymlinks();
+ var names = new[] { "npm", "npx", "corepack" };
+ var targets = new[]
+ {
+ "../lib/node_modules/npm/bin/npm-cli.js",
+ "../lib/node_modules/npm/bin/npx-cli.js",
+ "../lib/node_modules/corepack/dist/corepack.js",
+ };
+ var entries = names.Select((name, i) => LinkEntry($"{folder}/bin/{name}", targets[i])).ToList();
+ entries.Add(FileEntry($"{folder}/bin/node", "node"));
+ entries.AddRange(targets.Select(target => FileEntry($"{folder}/bin/{target}", "script")));
+ WriteArchive(entries.ToArray());
+
+ await TarGZipExtractor.ExtractAsync(archive, output);
+
+ // The installer moves the whole distribution after extraction; relative links must survive it.
+ var installed = Path.Combine(output, "nodejs");
+ Directory.Move(Path.Combine(output, folder), installed);
+ for (var i = 0; i < names.Length; i++)
+ {
+ var path = Path.Combine(installed, "bin", names[i]);
+ Assert.AreEqual(targets[i], new FileInfo(path).LinkTarget);
+ Assert.AreEqual("script", File.ReadAllText(path));
+ }
+ if (!OperatingSystem.IsWindows())
+ {
+ Assert.IsTrue(
+ File.GetUnixFileMode(Path.Combine(installed, "bin/node")).HasFlag(UnixFileMode.UserExecute)
+ );
+ }
+ }
+
+ [DataTestMethod]
+ [DataRow("../outside")]
+ [DataRow("../output with spaces-sibling/outside")]
+ [DataRow("/absolute")]
+ [DataRow("C:/outside")]
+ [DataRow("C:outside")]
+ [DataRow("\\\\server\\share\\outside")]
+ [DataRow("..\\outside")]
+ public async Task ExtractAsync_RejectsUnsafeEntryPaths(string name)
+ {
+ WriteArchive(FileEntry(name, "unsafe"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ Assert.IsFalse(File.Exists(Path.Combine(workspace, "outside")));
+ }
+
+ [DataTestMethod]
+ [DataRow("../../../outside")]
+ [DataRow("/absolute")]
+ [DataRow("C:/outside")]
+ [DataRow("..\\..\\outside")]
+ public async Task ExtractAsync_RejectsUnsafeLinkTargets(string target)
+ {
+ WriteArchive(LinkEntry("node/bin/npm", target));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+ }
+
+ [DataTestMethod]
+ [DataRow(TarEntryType.HardLink)]
+ [DataRow(TarEntryType.Fifo)]
+ [DataRow(TarEntryType.CharacterDevice)]
+ [DataRow(TarEntryType.BlockDevice)]
+ public async Task ExtractAsync_RejectsHardLinksAndSpecialFiles(TarEntryType type)
+ {
+ var entry = new PaxTarEntry(type, "unsafe");
+ if (type == TarEntryType.HardLink)
+ {
+ entry.LinkName = "../outside";
+ }
+ WriteArchive(entry);
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsMissingLinkTarget()
+ {
+ WriteArchive(LinkEntry("node/bin/npm", "../missing"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsTargetNotExtractedFromArchive()
+ {
+ Directory.CreateDirectory(output);
+ File.WriteAllText(Path.Combine(output, "existing"), "sentinel");
+ WriteArchive(LinkEntry("link", "existing"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(output, "existing")));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsAbsoluteEntryInsideDestination()
+ {
+ WriteArchive(FileEntry(Path.Combine(output, "file").Replace('\\', '/'), "unsafe"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsLinkedDestinationRoot()
+ {
+ RequireSymlinks();
+ var outside = Path.Combine(workspace, "outside");
+ Directory.CreateDirectory(outside);
+ Directory.CreateSymbolicLink(output, outside);
+ WriteArchive(FileEntry("file", "unsafe"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ Assert.IsFalse(File.Exists(Path.Combine(outside, "file")));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsArchiveDirectoryLinks()
+ {
+ WriteArchive(
+ new PaxTarEntry(TarEntryType.Directory, "node/lib/"),
+ LinkEntry("node/bin", "lib"),
+ FileEntry("node/bin/file", "unsafe")
+ );
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ Assert.IsFalse(File.Exists(Path.Combine(output, "node/lib/file")));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsLinkChains()
+ {
+ RequireSymlinks();
+ WriteArchive(FileEntry("file", "safe"), LinkEntry("link", "file"), LinkEntry("chain", "link"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+ }
+
+ [DataTestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public async Task ExtractAsync_RejectsExistingDirectoryLink(bool broken)
+ {
+ RequireSymlinks();
+ var outside = Path.Combine(workspace, "outside");
+ if (!broken)
+ {
+ Directory.CreateDirectory(outside);
+ File.WriteAllText(Path.Combine(outside, "file"), "sentinel");
+ }
+ Directory.CreateDirectory(output);
+ Directory.CreateSymbolicLink(Path.Combine(output, "node"), outside);
+ WriteArchive(FileEntry("node/file", "unsafe"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ if (!broken)
+ {
+ Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(outside, "file")));
+ }
+ else
+ {
+ Assert.IsFalse(Directory.Exists(outside));
+ }
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_RejectsExistingFileLink()
+ {
+ RequireSymlinks();
+ var outside = Path.Combine(workspace, "outside");
+ File.WriteAllText(outside, "sentinel");
+ Directory.CreateDirectory(output);
+ File.CreateSymbolicLink(Path.Combine(output, "file"), outside);
+ WriteArchive(FileEntry("file", "unsafe"));
+
+ await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output));
+
+ Assert.AreEqual("sentinel", File.ReadAllText(outside));
+ }
+
+ [TestMethod]
+ public async Task ExtractAsync_NormalizesLinkTargetBeforeCreatingLink()
+ {
+ RequireSymlinks();
+ var outside = Path.Combine(workspace, "outside");
+ Directory.CreateDirectory(Path.Combine(outside, "directory"));
+ File.WriteAllText(Path.Combine(outside, "file"), "sentinel");
+ Directory.CreateDirectory(output);
+ Directory.CreateSymbolicLink(Path.Combine(output, "existing"), Path.Combine(outside, "directory"));
+ WriteArchive(FileEntry("file", "safe"), LinkEntry("link", "existing/../file"));
+
+ await TarGZipExtractor.ExtractAsync(archive, output);
+
+ Assert.AreEqual("file", new FileInfo(Path.Combine(output, "link")).LinkTarget);
+ Assert.AreEqual("safe", File.ReadAllText(Path.Combine(output, "link")));
+ Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(outside, "file")));
+ }
+
+ private void RequireSymlinks()
+ {
+ var target = Path.Combine(workspace, "symlink-probe-target");
+ var link = Path.Combine(workspace, "symlink-probe");
+ File.WriteAllText(target, "probe");
+ try
+ {
+ File.CreateSymbolicLink(link, target);
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ Assert.Inconclusive($"This host cannot create symlinks: {e.Message}");
+ }
+ }
+
+ private void WriteArchive(params TarEntry[] entries)
+ {
+ using var stream = File.Create(archive);
+ using var gzip = new GZipStream(stream, CompressionMode.Compress);
+ using var writer = new TarWriter(gzip, TarEntryFormat.Pax);
+ foreach (var entry in entries)
+ {
+ writer.WriteEntry(entry);
+ entry.DataStream?.Dispose();
+ }
+ }
+
+ private static PaxTarEntry FileEntry(string name, string contents) =>
+ new(TarEntryType.RegularFile, name)
+ {
+ DataStream = new MemoryStream(Encoding.UTF8.GetBytes(contents)),
+ Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
+ };
+
+ private static PaxTarEntry LinkEntry(string name, string target) =>
+ new(TarEntryType.SymbolicLink, name) { LinkName = target };
+}
diff --git a/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs b/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs
new file mode 100644
index 000000000..7b2aae70b
--- /dev/null
+++ b/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Octokit;
+using StabilityMatrix.Core.Database;
+using StabilityMatrix.Core.Helper.Cache;
+using StabilityMatrix.Core.Models.Database;
+
+namespace StabilityMatrix.Tests.Helper;
+
+[TestClass]
+public class GithubApiCacheTests
+{
+ private readonly IGitHubClient github = Substitute.For();
+ private readonly ILiteDbContext dbContext = Substitute.For();
+ private GithubApiCache cache = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ dbContext.GetGithubCacheEntry(Arg.Any()).Returns((GithubCacheEntry?)null);
+ cache = new GithubApiCache(dbContext, github, Substitute.For>());
+ }
+
+ [TestMethod]
+ public async Task GetAllReleases_ConcurrentCallsForSameRepo_ShareOneRequest()
+ {
+ var response = new TaskCompletionSource>();
+ github
+ .Repository.Release.GetAll("Comfy-Org", "ComfyUI", Arg.Any())
+ .Returns(response.Task);
+
+ var calls = Enumerable.Range(0, 5).Select(_ => cache.GetAllReleases("Comfy-Org", "ComfyUI")).ToList();
+ response.SetResult([new Release()]);
+ var results = await Task.WhenAll(calls);
+
+ await github.Repository.Release.Received(1).GetAll("Comfy-Org", "ComfyUI", Arg.Any());
+ Assert.IsTrue(results.All(r => r.Count() == 1));
+ }
+
+ [TestMethod]
+ public async Task GetAllReleases_AfterRequestCompletes_FetchesAgainOnCacheMiss()
+ {
+ github
+ .Repository.Release.GetAll("Comfy-Org", "ComfyUI", Arg.Any())
+ .Returns([new Release()]);
+
+ await cache.GetAllReleases("Comfy-Org", "ComfyUI");
+ await cache.GetAllReleases("Comfy-Org", "ComfyUI");
+
+ await github.Repository.Release.Received(2).GetAll("Comfy-Org", "ComfyUI", Arg.Any());
+ }
+
+ [TestMethod]
+ public async Task GetAllBranches_ConcurrentCallsForSameRepo_ShareOneRequest()
+ {
+ var response = new TaskCompletionSource>();
+ github.Repository.Branch.GetAll("Comfy-Org", "ComfyUI").Returns(response.Task);
+
+ var calls = Enumerable.Range(0, 5).Select(_ => cache.GetAllBranches("Comfy-Org", "ComfyUI")).ToList();
+ response.SetResult([new Branch()]);
+ await Task.WhenAll(calls);
+
+ await github.Repository.Branch.Received(1).GetAll("Comfy-Org", "ComfyUI");
+ }
+}
diff --git a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs
index f3c040d4b..a29152f30 100644
--- a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs
+++ b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs
@@ -164,6 +164,8 @@ string outerName
[DataTestMethod]
[DataRow("diffusion_models")]
+ [DataRow("Diffusion_Models")]
+ [DataRow("z_alias")]
[DataRow("a_alias")]
[DataRow("sub", "alias")]
public void EnumerateFiles_RealFolderShadowedByLink_KeepsRealFolderPaths(params string[] linkSegments)
diff --git a/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs b/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs
new file mode 100644
index 000000000..1b5e84fa6
--- /dev/null
+++ b/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs
@@ -0,0 +1,68 @@
+using NSubstitute;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Helper.Cache;
+using StabilityMatrix.Core.Models.Packages;
+using StabilityMatrix.Core.Python;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.Rocm;
+
+namespace StabilityMatrix.Tests.Models.Packages;
+
+[TestClass]
+public class ComfyUIInstallConfigTests
+{
+ private readonly ComfyUI comfy = new(
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()
+ );
+
+ [TestMethod]
+ [DataRow("3.10.21", true)]
+ [DataRow("3.11.14", true)]
+ [DataRow("3.12.10", true)]
+ [DataRow("3.12.99", true)]
+ [DataRow("3.13.0", false)]
+ [DataRow("3.13.12", false)]
+ [DataRow("3.14.0", false)]
+ public void Requirements_OnlyConstrainNumpyOnInterpretersSupportedByNumpy1(
+ string pythonVersion,
+ bool expectLegacyConstraint
+ )
+ {
+ var config = comfy.GetStandardInstallConfig(PyVersion.Parse(pythonVersion), false);
+ var args = new PipInstallArgs([.. config.ExtraPipArgs])
+ .WithParsedFromRequirementsTxt(
+ "numpy>=1.25.0\nscipy\ntorch\ntorchvision",
+ config.RequirementsExcludePattern
+ )
+ .ToProcessArgs();
+
+ Assert.AreEqual(expectLegacyConstraint, args.Contains("numpy<2"));
+ Assert.IsTrue(args.Contains("numpy>=1.25.0"));
+ Assert.IsTrue(args.Contains("scipy"));
+ Assert.IsFalse(args.Contains("torch"));
+ Assert.IsFalse(args.Contains("torchvision"));
+ }
+
+ [TestMethod]
+ [DataRow(true, "cu126")]
+ [DataRow(false, "cu130")]
+ public void Python313_PreservesTorchAndRequirementsConfiguration(bool isLegacyNvidia, string cudaIndex)
+ {
+ var config = comfy.GetStandardInstallConfig(new PyVersion(3, 13, 0), isLegacyNvidia);
+
+ CollectionAssert.AreEqual(new[] { "requirements.txt" }, config.RequirementsFilePaths.ToArray());
+ Assert.AreEqual(cudaIndex, config.CudaIndex);
+ Assert.AreEqual("rocm7.2", config.RocmIndex);
+ Assert.AreEqual("xpu", config.XpuIndex);
+ Assert.AreEqual(" ", config.TorchaudioVersion);
+ Assert.IsTrue(config.UpgradePackages);
+ Assert.IsTrue(config.ForceReinstallTorch);
+ CollectionAssert.AreEqual(new[] { "typing-extensions>=4.15.0" }, config.PostInstallPipArgs.ToArray());
+ }
+}
diff --git a/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs b/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs
new file mode 100644
index 000000000..53089157b
--- /dev/null
+++ b/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs
@@ -0,0 +1,115 @@
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Helper.Cache;
+using StabilityMatrix.Core.Helper.HardwareInfo;
+using StabilityMatrix.Core.Models;
+using StabilityMatrix.Core.Models.Packages;
+using StabilityMatrix.Core.Models.Rocm;
+using StabilityMatrix.Core.Models.Settings;
+using StabilityMatrix.Core.Python;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.Rocm;
+
+namespace StabilityMatrix.Tests.Models.Packages;
+
+[TestClass]
+public class ComfyUISageAttentionUpdateTests
+{
+ private readonly IPyVenvRunner venv = Substitute.For();
+ private readonly IPipWheelService wheels = Substitute.For();
+ private readonly IRocmPackageHelper rocm = Substitute.For();
+ private readonly ISettingsManager settings = Substitute.For();
+ private ComfyUI comfy = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ settings.Settings.Returns(new Settings { PreferredGpu = new GpuInfo { Name = "NVIDIA RTX 4090" } });
+ rocm.GetCompatibility().Returns(new RocmCompatibilityResult { IsCompatible = true });
+ comfy = new ComfyUI(
+ Substitute.For(),
+ settings,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ wheels,
+ rocm
+ );
+ }
+
+ [TestMethod]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.0+cu128")]
+ [DataRow("2.2.0+cu130torch2.9.1.post6", "2.9.2+cu130")]
+ [DataRow("2.2.0+cu128torch2.10.0andhigher.post6", "2.11.0+cu128")]
+ [DataRow("2.2.0+cu130torch2.9.0andhigher.post4", "2.10.0+cu130")]
+ [DataRow("2.2.0+cu128torch2.8.0.post3", "2.8.1+cu128")]
+ public async Task CompatibleInstalledWheel_IsPreserved(string sage, string torch)
+ {
+ SetVersions(sage, torch);
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda);
+ Assert.AreEqual(0, wheels.ReceivedCalls().Count());
+ await venv.Received(1).PipShow("sageattention");
+ await venv.Received(1).PipShow("torch");
+ }
+
+ [TestMethod]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.10.0+cu128")]
+ [DataRow("2.2.0+cu128torch2.10.0andhigher.post6", "2.9.1+cu128")]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cu130")]
+ [DataRow("2.2.0", "2.10.0+cu130")]
+ [DataRow("malformed", "malformed")]
+ public async Task IncompatibleInstalledWheel_UsesDynamicInstaller(string sage, string torch)
+ {
+ SetVersions(sage, torch);
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda);
+ await wheels.Received(1).InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null);
+ }
+
+ [TestMethod]
+ [DataRow(null, "2.10.0+cu130")]
+ [DataRow("2.2.0+cu130torch2.10.0andhigher.post6", null)]
+ public async Task MissingPackage_DoesNotInstallSage(string? sage, string? torch)
+ {
+ SetVersions(sage, torch);
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda);
+ Assert.AreEqual(0, wheels.ReceivedCalls().Count());
+ }
+
+ [TestMethod]
+ public async Task WindowsRocm_RemainsOnItsOwnInstallerPath()
+ {
+ if (!StabilityMatrix.Core.Helper.Compat.IsWindows)
+ Assert.Inconclusive("Windows native ROCm guard");
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Rocm);
+ Assert.AreEqual(0, venv.ReceivedCalls().Count());
+ Assert.AreEqual(0, wheels.ReceivedCalls().Count());
+ }
+
+ [TestMethod]
+ public async Task InspectionFailure_DoesNotAbortComfyUpdate()
+ {
+ venv.PipShow("sageattention").ThrowsAsync(new InvalidOperationException("pip show failed"));
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda);
+ Assert.AreEqual(0, wheels.ReceivedCalls().Count());
+ }
+
+ [TestMethod]
+ public async Task RepairFailure_DoesNotAbortComfyUpdate()
+ {
+ SetVersions("2.2.0+cu128torch2.9.1.post6", "2.10.0+cu130");
+ wheels
+ .InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null)
+ .ThrowsAsync(new InvalidOperationException("installation failed"));
+ await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda);
+ await wheels.Received(1).InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null);
+ }
+
+ private void SetVersions(string? sage, string? torch)
+ {
+ venv.PipShow("sageattention")
+ .Returns(sage is null ? null : new PipShowResult { Name = "sageattention", Version = sage });
+ venv.PipShow("torch")
+ .Returns(torch is null ? null : new PipShowResult { Name = "torch", Version = torch });
+ }
+}
diff --git a/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs b/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs
new file mode 100644
index 000000000..97c67f3d4
--- /dev/null
+++ b/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs
@@ -0,0 +1,208 @@
+using NSubstitute;
+using StabilityMatrix.Core.Helper;
+using StabilityMatrix.Core.Helper.Cache;
+using StabilityMatrix.Core.Helper.HardwareInfo;
+using StabilityMatrix.Core.Processes;
+using StabilityMatrix.Core.Python;
+using StabilityMatrix.Core.Services;
+
+namespace StabilityMatrix.Tests.Services;
+
+[TestClass]
+public class PipWheelServiceTests
+{
+ private static readonly PyVersion Py310 = new(3, 10, 11);
+ private static readonly PyVersion Py312 = new(3, 12, 7);
+
+ // Real asset names from woct0rdho/SageAttention releases
+ private const string Post6Cu128Torch210 =
+ "sageattention-2.2.0+cu128torch2.10.0andhigher.post6-cp310-abi3-win_amd64.whl";
+ private const string Post6Cu130Torch291 =
+ "sageattention-2.2.0+cu130torch2.9.1.post6-cp310-abi3-win_amd64.whl";
+ private const string Post3Cu126Torch260 =
+ "sageattention-2.2.0+cu126torch2.6.0.post3-cp39-abi3-win_amd64.whl";
+ private const string V211Cu128Torch270Cp310 =
+ "sageattention-2.1.1+cu128torch2.7.0-cp310-cp310-win_amd64.whl";
+
+ [TestMethod]
+ public void AndHigherWheel_MatchesNewerTorch()
+ {
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.11.0", "cu128", Py312));
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu128", Py310));
+ }
+
+ [TestMethod]
+ public void AndHigherWheel_RejectsOlderTorch()
+ {
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.9.1", "cu128", Py312));
+ }
+
+ [TestMethod]
+ public void PinnedWheel_MatchesSameTorchMinorLine()
+ {
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.9.0", "cu130", Py312));
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.9.1", "cu130", Py310));
+ }
+
+ [TestMethod]
+ public void PinnedWheel_RejectsDifferentTorchMinor()
+ {
+ // The old prefix matching ("torch2.1" in "torch2.10...") must not resurface here
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.10.0", "cu130", Py312));
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post3Cu126Torch260, "2.60.0", "cu126", Py312));
+ }
+
+ [TestMethod]
+ public void Cp310Abi3Wheel_RequiresPython310OrNewer()
+ {
+ var py39 = new PyVersion(3, 9, 13);
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu128", py39));
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post3Cu126Torch260, "2.6.0", "cu126", py39));
+ }
+
+ [TestMethod]
+ public void InterpreterSpecificWheel_RequiresExactPython()
+ {
+ Assert.IsTrue(PipWheelService.IsMatchingSageWheel(V211Cu128Torch270Cp310, "2.7.0", "cu128", Py310));
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(V211Cu128Torch270Cp310, "2.7.0", "cu128", Py312));
+ }
+
+ [TestMethod]
+ public void CudaIndexMismatch_Rejects()
+ {
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu130", Py312));
+ Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "", Py312));
+ }
+
+ [TestMethod]
+ public void FallbackWheel_Torch210Cu130_UsesPost6AndHigher()
+ {
+ var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, null);
+ StringAssert.Contains(url, "cu130torch2.10.0andhigher.post6");
+ }
+
+ [TestMethod]
+ public void FallbackWheel_Torch29Cu128_UsesPost6Pinned()
+ {
+ var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.9.0+cu128"), Py310, null);
+ StringAssert.Contains(url, "cu128torch2.9.1.post6");
+ }
+
+ [TestMethod]
+ public void FallbackWheel_LegacyTorch_UsesPost3()
+ {
+ var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.6.0+cu126"), Py310, null);
+ StringAssert.Contains(url, "cu126torch2.6.0.post3");
+ }
+
+ [TestMethod]
+ public void FallbackWheel_UnknownCombination_ReturnsNull()
+ {
+ Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu126"), Py312, null));
+ Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.4.0+cu121"), Py310, null));
+ }
+
+ [TestMethod]
+ public void FallbackWheel_HonorsPinnedSageVersion()
+ {
+ Assert.IsNotNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, "2.2.0"));
+ Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, "9.9.9"));
+ }
+
+ [TestMethod]
+ [DataRow("2.9.0", true)]
+ [DataRow("2.10.0", true)]
+ [DataRow("2.11.0", true)]
+ [DataRow("2.8.1", false)]
+ [DataRow("invalid", false)]
+ public void Torch29AndHigherWheel_UsesSharedCompatibilityRules(string torch, bool expected)
+ {
+ const string version = "2.2.0+cu130torch2.9.0andhigher.post4";
+ Assert.AreEqual(expected, PipWheelService.IsCompatibleSageAttention(version, $"{torch}+cu130"));
+ Assert.AreEqual(
+ expected,
+ PipWheelService.IsMatchingSageWheel(
+ $"sageattention-{version}-cp39-abi3-win_amd64.whl",
+ torch,
+ "cu130",
+ Py312
+ )
+ );
+ }
+
+ [TestMethod]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cu1280")]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1")]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cpu")]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+rocm7.2")]
+ [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1.dev20260101+cu128")]
+ public void UnsupportedTorchMetadata_IsNotDeclaredCompatible(string sage, string torch)
+ {
+ Assert.IsFalse(PipWheelService.IsCompatibleSageAttention(sage, torch));
+ }
+
+ [TestMethod]
+ public async Task WindowsRepair_EmptyReleaseListUsesPost6WithoutTorchDependencies()
+ {
+ if (!Compat.IsWindows)
+ Assert.Inconclusive("Windows wheel installation");
+ var github = Substitute.For();
+ github.GetAllReleases("woct0rdho", "SageAttention").Returns([]);
+ var venv = Substitute.For();
+ venv.Version.Returns(Py312);
+ venv.PipShow("torch").Returns(TorchInfo("2.10.0+cu130"));
+ var service = new PipWheelService(
+ github,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()
+ );
+
+ await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "NVIDIA RTX 4090" });
+
+ await venv.Received(1)
+ .PipInstall(
+ Arg.Is(args =>
+ args.ToString().Contains("--no-deps")
+ && args.ToString().Contains("cu130torch2.10.0andhigher.post6")
+ ),
+ Arg.Any?>()
+ );
+ }
+
+ [TestMethod]
+ public async Task NonNvidiaGpu_DoesNotInspectOrInstallCudaWheels()
+ {
+ var github = Substitute.For();
+ var venv = Substitute.For();
+ var service = new PipWheelService(
+ github,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()
+ );
+ await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "AMD Radeon RX 9070" });
+ Assert.AreEqual(0, venv.ReceivedCalls().Count());
+ Assert.AreEqual(0, github.ReceivedCalls().Count());
+ }
+
+ [TestMethod]
+ public async Task WindowsMissingTorch_DoesNotLookUpOrInstallWheels()
+ {
+ if (!Compat.IsWindows)
+ Assert.Inconclusive("Windows wheel installation");
+ var github = Substitute.For();
+ var venv = Substitute.For();
+ var service = new PipWheelService(
+ github,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()
+ );
+ await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "NVIDIA RTX 4090" });
+ await venv.DidNotReceive().PipInstall(Arg.Any(), Arg.Any?>());
+ Assert.AreEqual(0, github.ReceivedCalls().Count());
+ }
+
+ private static PipShowResult TorchInfo(string version) => new() { Name = "torch", Version = version };
+}
diff --git a/StabilityMatrix.UITests/BananaVisionPageTests.cs b/StabilityMatrix.UITests/BananaVisionPageTests.cs
new file mode 100644
index 000000000..76b17be4d
--- /dev/null
+++ b/StabilityMatrix.UITests/BananaVisionPageTests.cs
@@ -0,0 +1,290 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using StabilityMatrix.Avalonia.Models.BananaVision;
+using StabilityMatrix.Avalonia.Services;
+using StabilityMatrix.Avalonia.ViewModels;
+using StabilityMatrix.Avalonia.ViewModels.Base;
+using StabilityMatrix.Avalonia.Views;
+using StabilityMatrix.Core.Helper.Factory;
+using StabilityMatrix.Core.Models.Database;
+using StabilityMatrix.Core.Python;
+using StabilityMatrix.Core.Services;
+using StabilityMatrix.Core.Services.ImageGeneration;
+
+namespace StabilityMatrix.UITests;
+
+///
+/// Image Lab page hosted headlessly over an in-memory chat service, so generation outcomes can be
+/// timed against conversation switches.
+///
+[Collection("TempDir")]
+public class BananaVisionPageTests
+{
+ [AvaloniaFact]
+ public async Task Generation_FinishingWhileAnotherConversationIsOpen_StaysOutOfIt()
+ {
+ foreach (var fails in new[] { false, true })
+ {
+ var lab = new ImageLab();
+ var a = lab.AddConversation("A", minutesAgo: 1, "A prompt", "A reply");
+ var b = lab.AddConversation("B", minutesAgo: 30, "B prompt", "B reply");
+ var generation = lab.HoldSend(fails ? new ImageGenerationException("boom") : null);
+ await lab.OpenAsync(a);
+
+ lab.Vm.NewMessageText = "A second prompt";
+ var send = lab.Vm.SendMessageCommand.ExecuteAsync(null);
+ await Settle();
+ await lab.SelectAsync(b);
+
+ generation.SetResult();
+ await send;
+ await Settle();
+
+ Assert.Equal("B", lab.Vm.CurrentConversation?.Title);
+ Assert.Equal(new[] { "B prompt", "B reply" }, lab.Texts());
+ Assert.False(lab.Vm.CanRetryLastMessage);
+
+ await lab.SelectAsync(a);
+ Assert.Equal(
+ fails
+ ? new[] { "A prompt", "A reply", "A second prompt" }
+ : new[] { "A prompt", "A reply", "A second prompt", "sent reply" },
+ lab.Texts()
+ );
+ Assert.Equal(fails, lab.Vm.CanRetryLastMessage);
+ Assert.All(lab.Vm.Messages.OfType(), m => Assert.True(m.CanDelete));
+ lab.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public async Task Regenerate_KeepsTheOldResponseUntilTheNewOneIsSaved()
+ {
+ var lab = new ImageLab();
+ var a = lab.AddConversation("A", minutesAgo: 1, "prompt", "old reply");
+ await lab.OpenAsync(a);
+
+ lab.FailRetry = true;
+ await lab.Vm.RegenerateLastResponseCommand.ExecuteAsync(null);
+ await Settle();
+ Assert.Equal(new[] { "prompt", "old reply" }, lab.Texts());
+ Assert.True(lab.Vm.CanRetryLastMessage);
+
+ lab.FailRetry = false;
+ await lab.Vm.RegenerateLastResponseCommand.ExecuteAsync(null);
+ await Settle();
+ Assert.Equal(new[] { "prompt", "regenerated reply" }, lab.Texts());
+ lab.Close();
+ }
+
+ [AvaloniaFact]
+ public async Task NewMessage_WhenAtTheBottom_ScrollsToIt()
+ {
+ var lab = new ImageLab();
+ var a = lab.AddConversation("A", minutesAgo: 1);
+ for (var i = 0; i < 12; i++)
+ {
+ lab.AddMessages(a, $"prompt {i} " + new string('x', 400), $"reply {i}");
+ }
+ lab.HoldSend(null);
+ await lab.OpenAsync(a);
+
+ var scroll = lab.Page.FindControl("MessageScrollViewer")!;
+ Assert.Equal(scroll.Extent.Height - scroll.Viewport.Height, scroll.Offset.Y, 1);
+
+ lab.Vm.NewMessageText = "one more";
+ _ = lab.Vm.SendMessageCommand.ExecuteAsync(null);
+ await Settle();
+
+ // The loading placeholder alone is taller than the near-bottom threshold
+ Assert.Equal(scroll.Extent.Height - scroll.Viewport.Height, scroll.Offset.Y, 1);
+ lab.Close();
+ }
+
+ private static async Task Settle()
+ {
+ for (var i = 0; i < 10; i++)
+ {
+ await Task.Delay(30);
+ Dispatcher.UIThread.RunJobs();
+ }
+ }
+
+ private sealed class ImageLab
+ {
+ private readonly List conversations = [];
+ private readonly Dictionary> messages = [];
+ private readonly IImageGenerationChatService chatService =
+ Substitute.For();
+ private Window? window;
+
+ public ImageLab()
+ {
+ chatService.GetAvailableProviders().Returns([]);
+ chatService
+ .GetConversationsAsync()
+ .Returns(_ =>
+ conversations.OrderByDescending(c => c.UpdatedAt).Select(c => c with { }).ToList()
+ );
+ chatService.GetMessagesAsync(Arg.Any()).Returns(ci => messages[ci.Arg()].ToList());
+ chatService
+ .DeleteMessageAsync(Arg.Any(), Arg.Any())
+ .Returns(ci =>
+ {
+ foreach (var list in messages.Values)
+ list.RemoveAll(m => m.Id == ci.ArgAt(0));
+ return Task.CompletedTask;
+ });
+ chatService
+ .RetryGenerationAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any?>(),
+ Arg.Any()
+ )
+ .Returns(ci =>
+ {
+ if (FailRetry)
+ return Task.FromException(
+ new ImageGenerationException("failed")
+ );
+ Touch(ci.ArgAt(0));
+ return Task.FromResult(
+ Save(ci.ArgAt(0), MessageRole.Assistant, "regenerated reply")
+ );
+ });
+
+ Vm = new BananaVisionPageViewModel(
+ NullLogger.Instance,
+ chatService,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ new RunningPackageService(
+ NullLogger.Instance,
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For(),
+ Substitute.For()
+ ),
+ Substitute.For>(),
+ Substitute.For(),
+ Substitute.For>(),
+ Substitute.For>()
+ )
+ {
+ SelectedProviderId = BananaVisionProviderIds.Gemini3Pro,
+ };
+ Page = new BananaVisionPage { DataContext = Vm };
+ }
+
+ public BananaVisionPageViewModel Vm { get; }
+
+ public BananaVisionPage Page { get; }
+
+ public bool FailRetry { get; set; }
+
+ public ImageGenerationConversation AddConversation(
+ string title,
+ int minutesAgo,
+ params string[] turns
+ )
+ {
+ var conversation = new ImageGenerationConversation
+ {
+ Title = title,
+ ProviderId = BananaVisionProviderIds.Gemini3Pro,
+ UpdatedAt = DateTime.UtcNow.AddMinutes(-minutesAgo),
+ };
+ conversations.Add(conversation);
+ messages[conversation.Id] = [];
+ AddMessages(conversation, turns);
+ return conversation;
+ }
+
+ public void AddMessages(ImageGenerationConversation conversation, params string[] turns)
+ {
+ for (var i = 0; i < turns.Length; i++)
+ {
+ Save(conversation.Id, i % 2 == 0 ? MessageRole.User : MessageRole.Assistant, turns[i]);
+ }
+ }
+
+ ///
+ /// Makes Send save the prompt and then wait; completing the returned source finishes it,
+ /// with a reply or by throwing .
+ ///
+ public TaskCompletionSource HoldSend(Exception? failure)
+ {
+ var gate = new TaskCompletionSource();
+ chatService
+ .SendMessageAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any?>(),
+ Arg.Any?>(),
+ Arg.Any()
+ )
+ .Returns(async ci =>
+ {
+ var id = ci.ArgAt(0);
+ var user = Save(id, MessageRole.User, ci.ArgAt(2));
+ await gate.Task;
+ Touch(id);
+ if (failure != null)
+ throw failure;
+ return (user, (ImageGenerationMessage?)Save(id, MessageRole.Assistant, "sent reply"));
+ });
+ return gate;
+ }
+
+ public async Task OpenAsync(ImageGenerationConversation conversation)
+ {
+ window = new Window
+ {
+ Width = 1400,
+ Height = 900,
+ Content = Page,
+ };
+ window.Show();
+ foreach (var c in await chatService.GetConversationsAsync())
+ {
+ Vm.Conversations.Add(c);
+ }
+ await SelectAsync(conversation);
+ }
+
+ /// Selects the conversation the way the sidebar does.
+ public async Task SelectAsync(ImageGenerationConversation conversation)
+ {
+ Vm.CurrentConversation = Vm.Conversations.First(c => c.Id == conversation.Id);
+ await Settle();
+ }
+
+ public string[] Texts() => Vm.Messages.OfType().Select(m => m.Text).ToArray();
+
+ public void Close() => window?.Close();
+
+ private ImageGenerationMessage Save(Guid conversationId, MessageRole role, string? text)
+ {
+ var message = new ImageGenerationMessage
+ {
+ ConversationId = conversationId,
+ Role = role,
+ TextContent = text,
+ Timestamp = DateTime.UtcNow.AddTicks(messages[conversationId].Count),
+ };
+ messages[conversationId].Add(message);
+ return message;
+ }
+
+ /// Bumps the conversation to the top of the list, as a generation does.
+ private void Touch(Guid conversationId) =>
+ conversations.First(c => c.Id == conversationId).UpdatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs b/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs
new file mode 100644
index 000000000..07aa3fa64
--- /dev/null
+++ b/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs
@@ -0,0 +1,92 @@
+using System.Reflection;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using StabilityMatrix.Avalonia.Controls;
+using TheArtOfDev.HtmlRenderer.Avalonia;
+
+namespace StabilityMatrix.UITests;
+
+[Collection("TempDir")]
+public class MarkdownViewerLayoutTests
+{
+ private static readonly FieldInfo HtmlContainerField = typeof(HtmlControl).GetField(
+ "_htmlContainer",
+ BindingFlags.Instance | BindingFlags.NonPublic
+ )!;
+
+ private static readonly string LongText = string.Join(
+ " ",
+ Enumerable.Repeat("Keep his face shape, snout and ear shape identical.", 12)
+ );
+
+ [AvaloniaFact]
+ public void LongText_InHeightUnboundedHost_WrapsWithinTheControl()
+ {
+ foreach (var fitToContent in new[] { false, true })
+ {
+ var (label, container) = LayOutInBubble(LongText, fitToContent);
+
+ Assert.True(container.ActualSize.Height > 100, "text should wrap onto several lines");
+ Assert.True(
+ container.ActualSize.Width <= label.Bounds.Width + 0.5,
+ $"text laid out {container.ActualSize.Width} wide in a {label.Bounds.Width} wide control"
+ );
+ Assert.True(label.Bounds.Width <= 520, $"control is {label.Bounds.Width} wide, offered 520");
+ }
+ }
+
+ [AvaloniaFact]
+ public void ShortText_FitToContent_TakesOnlyTheWidthItNeeds()
+ {
+ var (fitted, fittedContainer) = LayOutInBubble("hi!", fitToContent: true);
+ var (filled, _) = LayOutInBubble("hi!", fitToContent: false);
+
+ Assert.True(fitted.DesiredSize.Width < 100, $"fitted control wants {fitted.DesiredSize.Width}");
+ Assert.True(fittedContainer.ActualSize.Width <= fitted.Bounds.Width + 0.5);
+ Assert.Equal(520, filled.DesiredSize.Width, 0.5);
+ }
+
+ ///
+ /// Lays the text out in a host shaped like an Image Lab chat bubble: an Auto grid column offers
+ /// unbounded width, the bubble's MaxWidth bounds it, and the StackPanel offers unbounded height.
+ ///
+ private static (HtmlControl Label, HtmlContainer Container) LayOutInBubble(string text, bool fitToContent)
+ {
+ var viewer = new MarkdownViewer { Text = text, FitToContent = fitToContent };
+ var window = new Window
+ {
+ Width = 800,
+ Height = 600,
+ Content = new ScrollViewer
+ {
+ Content = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto"),
+ Children =
+ {
+ new Border
+ {
+ MaxWidth = 550,
+ Padding = new Thickness(14, 10),
+ Child = new StackPanel { MaxWidth = 520, Children = { viewer } },
+ },
+ },
+ },
+ },
+ };
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ var label = viewer.GetVisualDescendants().OfType().Single();
+ return (label, (HtmlContainer)HtmlContainerField.GetValue(label)!);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+}
diff --git a/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs b/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs
new file mode 100644
index 000000000..6ce6f97e6
--- /dev/null
+++ b/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs
@@ -0,0 +1,162 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using ColorTextBlock.Avalonia;
+using Microsoft.Extensions.DependencyInjection;
+using StabilityMatrix.Avalonia.Controls;
+using StabilityMatrix.Avalonia.ViewModels.Dialogs;
+using StabilityMatrix.Avalonia.Views.Dialogs;
+
+namespace StabilityMatrix.UITests;
+
+[Collection("TempDir")]
+public class ReleaseNotesLayoutTests
+{
+ private static string StressNotes =>
+ "## Changed\n\n"
+ + string.Join(
+ " ",
+ Enumerable.Repeat("Long release notes must remain readable beside the scrollbar.", 40)
+ )
+ + "\n\n## Fixed\n\n- **Windows ROCm** packages now come from AMD's permanent repositories, "
+ + string.Join(
+ " ",
+ Enumerable.Repeat(
+ "including `inline code` and [linked release notes](https://example.org/releases).",
+ 25
+ )
+ )
+ + "\n - Nested lists also need to wrap without covering the scrollbar.\n\n"
+ + "```python\nprint('"
+ + new string('x', 300)
+ + "')\n```\n\n"
+ + string.Join("\n\n", Enumerable.Repeat("A final paragraph after the wide code block.", 20));
+
+ [AvaloniaFact]
+ public void ReleaseNotes_FitBesideScrollbar_AfterResizeAndScale()
+ {
+ foreach (var scale in new[] { 1.0, 1.25, 1.5, 2.0 })
+ {
+ var viewer = new ReleaseNotesMarkdownViewer { Markdown = StressNotes };
+ var window = new Window
+ {
+ Width = 700,
+ Height = 400,
+ Content = new LayoutTransformControl
+ {
+ Child = viewer,
+ LayoutTransform = new ScaleTransform(scale, scale),
+ },
+ };
+ try
+ {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ foreach (var width in new[] { 700, 520, 400, 700 })
+ {
+ window.Width = width;
+ Dispatcher.UIThread.RunJobs();
+ AssertDocumentFits(viewer);
+ var scroll = InnerScrollViewer(viewer);
+ scroll.Offset = new Vector(0, scroll.Extent.Height);
+ Dispatcher.UIThread.RunJobs();
+ Assert.True(scroll.Offset.Y > 0);
+ AssertDocumentFits(viewer);
+ }
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+ }
+
+ [AvaloniaFact]
+ public async Task UpdateDialog_PreloadedNotes_FitBesideScrollbar()
+ {
+ var model = TestBase.Services.GetRequiredService();
+ model.ShowProgressBar = false;
+ model.ReleaseNotes = StressNotes;
+ var dialog = new UpdateDialog { DataContext = model };
+ var window = new Window
+ {
+ Width = 700,
+ Height = 700,
+ Content = new Grid(),
+ };
+ var host = new BetterContentDialog
+ {
+ Content = dialog,
+ ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ IsFooterVisible = false,
+ };
+ try
+ {
+ window.Show();
+ var showTask = host.ShowAsync(window);
+ Dispatcher.UIThread.RunJobs();
+ var viewer = dialog.GetVisualDescendants().OfType().Single();
+ foreach (var width in new[] { 700, 632, 520 })
+ {
+ window.Width = width;
+ Dispatcher.UIThread.RunJobs();
+ Assert.True(viewer.Bounds.Height > 50);
+ AssertDocumentFits(viewer);
+ }
+
+ var scroll = InnerScrollViewer(viewer);
+ scroll.Offset = new Vector(0, 1400);
+ Dispatcher.UIThread.RunJobs();
+ SaveQaScreenshot(window);
+ host.Hide();
+ Dispatcher.UIThread.RunJobs();
+ await showTask;
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ private static ScrollViewer InnerScrollViewer(BetterMarkdownScrollViewer viewer) =>
+ viewer.GetVisualChildren().OfType().Single();
+
+ private static void AssertDocumentFits(BetterMarkdownScrollViewer viewer)
+ {
+ var scroll = InnerScrollViewer(viewer);
+ var bar = scroll
+ .GetVisualDescendants()
+ .OfType()
+ .Last(b => b.Orientation == Orientation.Vertical);
+ Assert.True(bar.IsVisible);
+ var contentRight = bar.TranslatePoint(new Point(), scroll)!.Value.X;
+ var texts = viewer.GetVisualDescendants().OfType().ToArray();
+ Assert.NotEmpty(texts);
+ foreach (var text in texts)
+ {
+ var position = text.TranslatePoint(new Point(), scroll)!.Value;
+ Assert.True(text.Bounds.Height > 0);
+ Assert.True(
+ position.X + text.Bounds.Width <= contentRight + 1,
+ $"Text right={position.X + text.Bounds.Width}, scrollbar left={contentRight}, viewer width={viewer.Bounds.Width}"
+ );
+ }
+ }
+
+ private static void SaveQaScreenshot(Window window)
+ {
+ if (Environment.GetEnvironmentVariable("SM_RELEASE_NOTES_QA_DIR") is not { Length: > 0 } directory)
+ return;
+
+ Directory.CreateDirectory(directory);
+ using var bitmap = new RenderTargetBitmap(
+ new PixelSize((int)window.Bounds.Width, (int)window.Bounds.Height)
+ );
+ bitmap.Render(window);
+ bitmap.Save(Path.Combine(directory, "release-notes-dialog.png"));
+ }
+}
diff --git a/StabilityMatrix.UITests/TestAppBuilder.cs b/StabilityMatrix.UITests/TestAppBuilder.cs
index c3b752c24..0f9ad4f3a 100644
--- a/StabilityMatrix.UITests/TestAppBuilder.cs
+++ b/StabilityMatrix.UITests/TestAppBuilder.cs
@@ -1,4 +1,6 @@
-using Microsoft.Extensions.DependencyInjection;
+using System.Collections;
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -13,6 +15,8 @@
using StabilityMatrix.Core.Updater;
using StabilityMatrix.UITests;
using StabilityMatrix.UITests.Fakes;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Avalonia;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
@@ -31,7 +35,27 @@ public static AppBuilder BuildAvaloniaApp()
return AppBuilder
.Configure()
.UseSkia()
- .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false });
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
+ .AfterSetup(_ => ClearHtmlRendererFontCache());
+ }
+
+ ///
+ /// HtmlRenderer (behind MarkdownViewer) caches fonts in a process-wide static, while each
+ /// headless test gets its own FontManager that is disposed when the test ends. A later test
+ /// would then measure text with a freed HarfBuzz font and crash the test host, so the cache is
+ /// emptied as each test's app is set up. The app itself has one FontManager for its lifetime.
+ ///
+ private static void ClearHtmlRendererFontCache()
+ {
+ const BindingFlags instance = BindingFlags.Instance | BindingFlags.NonPublic;
+ var adapter = typeof(HtmlControl)
+ .Assembly.GetType("TheArtOfDev.HtmlRenderer.Avalonia.Adapters.AvaloniaAdapter")!
+ .GetProperty("Instance", BindingFlags.Static | BindingFlags.Public)!
+ .GetValue(null)!;
+ var fontsHandler = typeof(RAdapter).GetField("_fontsHandler", instance)!.GetValue(adapter)!;
+ var fontsCache = (IDictionary)
+ fontsHandler.GetType().GetField("_fontsCache", instance)!.GetValue(fontsHandler)!;
+ fontsCache.Clear();
}
private static void ConfigureGlobals()
diff --git a/docs/release-notes/2.16.4.md b/docs/release-notes/2.16.4.md
index d28cc22f2..714ede723 100644
--- a/docs/release-notes/2.16.4.md
+++ b/docs/release-notes/2.16.4.md
@@ -11,6 +11,7 @@ Extended technical notes for the v2.16.4 release — the deeper "how and why" be
- [ComfyUI Cross Attention Launch Options](#comfyui-cross-attention-launch-options)
- [Windows ROCm Package Repositories](#windows-rocm-package-repositories)
- [AI-Toolkit on Pre-2.16.3 Installs](#ai-toolkit-on-pre-2163-installs)
+- [Package Environment Variables and User Overrides](#package-environment-variables-and-user-overrides)
- [Wan2GP Deepy and the Gradio Logging Wrapper](#wan2gp-deepy-and-the-gradio-logging-wrapper)
## Guarding User Data During Install and Uninstall
@@ -61,6 +62,21 @@ Contributed by [@NeuralFault](https://github.com/NeuralFault).
v2.16.3 moved AI-Toolkit to Python 3.12 and set `SETUPTOOLS_USE_DISTUTILS=local` so setuptools uses its bundled distutils. Installs from before v2.16.3 still run Python 3.11, where the standard-library distutils is present and the *local* setting re-arms setuptools' distutils shim, crashing jobs with a `distutils` error ([#1725](https://github.com/LykosAI/StabilityMatrix/issues/1725)). The environment setup now reads the venv's real Python version and uses *stdlib* below 3.12 and *local* on 3.12 and up, so older installs keep working without a reinstall.
+## Package Environment Variables and User Overrides
+
+The AI-Toolkit fix above turned out to be one instance of a wider pattern: several packages set their own workaround environment variables — most commonly `SETUPTOOLS_USE_DISTUTILS` — directly on the venv process after it had already been seeded with whatever you'd configured under **Settings → Environment Variables**. That ordering meant a value you set yourself to fix something on your own machine was silently overwritten the moment the package launched.
+
+This surfaced on Wan2GP: it forced `SETUPTOOLS_USE_DISTUTILS=stdlib` unconditionally on every launch, which crashes with `ModuleNotFoundError: No module named 'distutils'` on a Python 3.12+ venv, since `distutils` was removed from the standard library entirely at that version. No environment variable override could survive to work around it, because the code never checked for one before overwriting it. The same unconditional force existed in AI Toolkit's SageAttention source build; VladAutomatic's equivalent path already had a Python-version guard.
+
+Two changes:
+
+- **User env vars win.** A new `Settings.IsEnvironmentVariableUserOverridden(key)` checks your actual configured environment variables (list or legacy dict format, disabled entries excluded), independent of what a package's own code has already written into the in-progress environment dictionary. The `env.SetPackageDefault(settingsManager, key, value)` extension applies a package's computed default only when that check comes back false, and replaces every unconditional `SETUPTOOLS_USE_DISTUTILS` write (Wan2GP, AI Toolkit, VladAutomatic, and PipWheelService's SageAttention build) along with Cogstudio's `UV_SKIP_WHEEL_FILENAME_CHECK`. Checking against your real settings rather than "is this key currently present" also means a package can still legitimately override its own base class's default — Reforge's Stable Diffusion repository URL over A3WebUI's, for one — without that being mistaken for a user override.
+- **`SETUPTOOLS_USE_DISTUTILS` is version-gated everywhere it's set.** Wan2GP now picks `local` on Python 3.12+ and `stdlib` below it, matching the guard AI Toolkit gained above (`local` uses setuptools' own vendored `distutils` copy, which works on any Python version; `stdlib` requires the real module to exist). PipWheelService's SageAttention build gained the same guard VladAutomatic already had, having previously set the workaround unconditionally on every Python version.
+
+Cogstudio's temporary `UV_SKIP_WHEEL_FILENAME_CHECK` override — needed for one specific pinned wheel with a mismatched internal version — also had a cleanup step that unconditionally removed the variable afterward, which would have erased a value you'd set yourself for the rest of the venv's life. That's now a small reusable helper, `RunWithTemporaryEnvironmentVariableAsync`, which snapshots whatever was there before the override and restores exactly that when the operation finishes or throws.
+
+Deliberately out of scope: install-path-derived variables like `INVOKEAI_ROOT`, `COMFYUI_PATH`, and `UV_BUILD_CONSTRAINT`. Those record facts about the install rather than tunable workarounds, so letting a user override them would just break the launch rather than fix anything.
+
## Wan2GP Deepy and the Gradio Logging Wrapper
Wan2GP v13's **Deepy** panel repeatedly showed **Connection to server lost** and logged `issubclass() arg 1 must be a class` ([Wan2GP #2298](https://github.com/deepbeepmeep/Wan2GP/issues/2298)). Deepy registers `gradio.Error` as a FastAPI exception handler. Stability Matrix's console logging wrapper, which mirrors Gradio error messages to stderr so they appear in the package console, had replaced that class with a plain function, so the mounted app failed to build its middleware stack. Diagnosis was confirmed from [NeuralFault's comment](https://github.com/deepbeepmeep/Wan2GP/issues/2298#issuecomment-5657302034) on the upstream issue.
diff --git a/docs/release-notes/2.16.5.md b/docs/release-notes/2.16.5.md
new file mode 100644
index 000000000..805b85f9e
--- /dev/null
+++ b/docs/release-notes/2.16.5.md
@@ -0,0 +1,66 @@
+# v2.16.5 Technical Notes
+
+Extended technical notes for the v2.16.5 release — the deeper "how and why" behind each change. For the user-facing summary, see the [CHANGELOG](https://github.com/LykosAI/StabilityMatrix/blob/main/CHANGELOG.md).
+
+[`Home`](../README.md)
+
+## Table of Contents
+
+- [Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13](#wan2gp-on-python-3-11-pytorch-2-10-and-cuda-13)
+- [Node.js Extraction on Linux and macOS](#node-js-extraction-on-linux-and-macos)
+- [Shared Model Folder Links Hiding DiffusionModels](#shared-model-folder-links-hiding-diffusionmodels)
+- [Uninstall Size with Symlink Sharing](#uninstall-size-with-symlink-sharing)
+
+## Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13
+
+Wan2GP's own installer (the GPU profiles in its `setup_config.json`) moved RTX 20XX and newer to Python 3.11 with a CUDA 13 build of PyTorch, keeping Python 3.10 only for GTX 10XX. Stability Matrix still installed Python 3.10 attention wheels, so a Python 3.11 venv failed with `sageattention ... has no wheels with a matching Python version tag`.
+
+The NVIDIA install now chooses its stack from the venv's Python version:
+
+- **Python 3.11 and newer** (any NVIDIA card except GTX 10XX): torch 2.10.0, torchvision 0.25.0 and torchaudio 2.10.0 from the `cu130` index, with their dependencies, so the CUDA 13 runtime (and Triton, on Linux) comes along. Ampere (RTX 30XX) and newer cards also get:
+ - Windows: `triton-windows` 3.6, SageAttention through the same wheel resolver ComfyUI uses (woct0rdho's post6 build for CUDA 13 and torch 2.10 or newer), and Wan2GP's Flash Attention wheel, which is published for Python 3.11 only.
+ - Linux: Wan2GP's own SageAttention build (the `cg1` wheel for CUDA 13 and torch 2.10). Flash Attention is skipped, since Wan2GP's Linux installer builds it from source.
+- **Python 3.10**: the torch 2.7 stack as before, with `triton-windows` 3.3.1 and the Python 3.10 attention wheels on Ampere and newer.
+
+RTX 20XX gets the CUDA 13 PyTorch build and, as before, no Triton or attention wheels. New installs default to Python 3.11, or 3.10 on GTX 10XX.
+
+### Existing installs keep their stack
+
+Updates reuse the install's recorded Python version, and Wan2GP declares no minimum version that would trigger the venv-recreating upgrade prompt, so an install created on Python 3.10 keeps torch 2.7.1 and `triton-windows` 3.3.1 (on Windows) on every update. Triton can't be bumped alone: `triton-windows` 3.4 and later pair with torch 2.8 and later. Newer Wan2GP models that compile Triton kernels, such as LTX 2.5, can fail on that stack ([#1756](https://github.com/LykosAI/StabilityMatrix/issues/1756)).
+
+**Change Version** offers no Python choice either, so moving to the new stack means a fresh install:
+
+1. In the Package Manager, add Wan2GP again under a different name. The installer won't accept a name that's already in use.
+2. Open **Advanced Options** and check that **Python Version** is 3.11 (the default on RTX 20XX and newer).
+3. Once it's installed and working, copy your outputs and anything else you want to keep from the old install folder, then uninstall the old copy.
+
+### Driver check at launch
+
+CUDA 13 needs NVIDIA driver 580 or newer. Wan2GP's launch now runs the same check as ComfyUI and AI Toolkit: on an NVIDIA card other than GTX 10XX, if the driver's major version is below 580 and the venv's torch is a `+cu130` build, it prints a console warning recommending a driver update (or an older torch index such as cu128) and stops the launch. Python 3.10 installs, and systems where the driver version can't be read, launch unchecked.
+
+## Node.js Extraction on Linux and macOS
+
+On Linux and macOS, AI Toolkit's Node.js prerequisite comes from the official v20.19.3 `.tar.gz`, which contains relative symlinks for `npm`, `npx` and `corepack`. The bundled 7-Zip rejects those as dangerous link paths, so AI Toolkit installs on Linux failed before Python setup started ([#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767)).
+
+Rather than turning 7-Zip's protection off, the Unix Node install now goes through a small dedicated extractor built on .NET's `System.Formats.Tar`. It extracts directories and regular files first, keeping their Unix permissions so `node` stays executable, and creates the symlinks only after every file is out, so no archive entry can write through a link the same archive created. Each link is written as a relative link to its checked, normalized target. Extraction fails on:
+
+- absolute paths, backslash or drive/UNC spellings, and any path that resolves outside the destination;
+- a link whose target is not a regular file extracted from the same archive, which also rules out directory links, link chains and missing targets;
+- hard links and special entries such as devices and FIFOs;
+- an existing symlink or junction anywhere along a destination path.
+
+Windows Node installs and every other archive extraction keep their existing behavior.
+
+## Shared Model Folder Links Hiding DiffusionModels
+
+Every SwarmUI install gets `Models/diffusion_models`, a link to `Models/DiffusionModels`. Since 2.16.4 the model index follows links but scans each physical folder at most once (see [Workflow Library Link Loops](2.16.4.md#workflow-library-link-loops)), so the alias and the real folder competed for one folder, and the scan order let the last-listed entry win. On NTFS `_` sorts after uppercase letters, so `diffusion_models` won every time. Diffusion models were then indexed only under the alias, whose name maps to no model type, so the Checkpoint Manager showed bare filenames without previews or metadata, and Image Lab and Inference missed them ([#1749](https://github.com/LykosAI/StabilityMatrix/issues/1749), [#1753](https://github.com/LykosAI/StabilityMatrix/issues/1753), [#1755](https://github.com/LykosAI/StabilityMatrix/issues/1755)).
+
+The rule now: the real directory tree is scanned first, then links are followed in order of how many links each path passes through, and the first path to reach a physical folder owns it. A real folder is the zero-link case, so it always beats an alias. If `DiffusionModels` is itself a link to another drive and the alias points at that link, the direct link is one hop and the alias two, so name sort order (or Linux's unordered listing) never decides. Links are resolved one at a time through each stored target, which is what counts the hops and also stops resolution passing through `subst` drive letters, where every file had been listed twice.
+
+Not covered: a hand-made link whose stored target uses a different drive alias (`T:\...` while the library is reached through `C:\...`) can still list files twice. Stability Matrix's own links are spelled from the models folder, so they can't hit this.
+
+Diagnosed and first fixed by [@NeuralFault](https://github.com/NeuralFault) ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743)), with the link-count ordering by [@ionite34](https://github.com/ionite34) ([#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)).
+
+## Uninstall Size with Symlink Sharing
+
+For a package using symlink sharing, the package size shown in the uninstall confirmation (added in 2.16.4) could be the size of the whole shared model library. The count skipped links only among the package folder's direct children, then recursed with options that follow nested links, and symlink sharing puts its links one level down (`models/checkpoints`). It's now one enumeration that skips links at every depth and still counts hidden files, shared with the size shown when installing over an existing folder. Only the number was wrong: the delete checks for links at every level and only unlinks them, so shared models were never removed.
diff --git a/docs/release-notes/2.17.0-pre.2.md b/docs/release-notes/2.17.0-pre.2.md
new file mode 100644
index 000000000..627676f7c
--- /dev/null
+++ b/docs/release-notes/2.17.0-pre.2.md
@@ -0,0 +1,101 @@
+# v2.17.0-pre.2 Technical Notes
+
+Extended technical notes for the v2.17.0-pre.2 release — the deeper "how and why" behind each change. For the user-facing summary, see the [CHANGELOG](https://github.com/LykosAI/StabilityMatrix/blob/main/CHANGELOG.md).
+
+[`Home`](../README.md)
+
+## Table of Contents
+
+- [Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13](#wan2gp-on-python-3-11-pytorch-2-10-and-cuda-13)
+- [Package Environment Variables No Longer Silently Override Your Settings](#package-environment-variables-no-longer-silently-override-your-settings)
+- [Node.js Extraction on Linux and macOS](#node-js-extraction-on-linux-and-macos)
+- [Shared Model Folder Links Hiding DiffusionModels](#shared-model-folder-links-hiding-diffusionmodels)
+- [Uninstall Size with Symlink Sharing](#uninstall-size-with-symlink-sharing)
+- [Where Model Categories Are Stored](#where-model-categories-are-stored)
+- [Thumbnail Cache for Local Images](#thumbnail-cache-for-local-images)
+
+## Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13
+
+Wan2GP's own installer (the GPU profiles in its `setup_config.json`) moved RTX 20XX and newer to Python 3.11 with a CUDA 13 build of PyTorch, keeping Python 3.10 only for GTX 10XX. Stability Matrix still installed Python 3.10 attention wheels, so a Python 3.11 venv failed with `sageattention ... has no wheels with a matching Python version tag`.
+
+The NVIDIA install now chooses its stack from the venv's Python version:
+
+- **Python 3.11 and newer** (any NVIDIA card except GTX 10XX): torch 2.10.0, torchvision 0.25.0 and torchaudio 2.10.0 from the `cu130` index, with their dependencies, so the CUDA 13 runtime (and Triton, on Linux) comes along. Ampere (RTX 30XX) and newer cards also get:
+ - Windows: `triton-windows` 3.6, SageAttention through the same wheel resolver ComfyUI uses (woct0rdho's post6 build for CUDA 13 and torch 2.10 or newer), and Wan2GP's Flash Attention wheel, which is published for Python 3.11 only.
+ - Linux: Wan2GP's own SageAttention build (the `cg1` wheel for CUDA 13 and torch 2.10). Flash Attention is skipped, since Wan2GP's Linux installer builds it from source.
+- **Python 3.10**: the torch 2.7 stack as before, with `triton-windows` 3.3.1 and the Python 3.10 attention wheels on Ampere and newer.
+
+RTX 20XX gets the CUDA 13 PyTorch build and, as before, no Triton or attention wheels. New installs default to Python 3.11, or 3.10 on GTX 10XX.
+
+### Existing installs keep their stack
+
+Updates reuse the install's recorded Python version, and Wan2GP declares no minimum version that would trigger the venv-recreating upgrade prompt, so an install created on Python 3.10 keeps torch 2.7.1 and `triton-windows` 3.3.1 (on Windows) on every update. Triton can't be bumped alone: `triton-windows` 3.4 and later pair with torch 2.8 and later. Newer Wan2GP models that compile Triton kernels, such as LTX 2.5, can fail on that stack ([#1756](https://github.com/LykosAI/StabilityMatrix/issues/1756)).
+
+**Change Version** offers no Python choice either, so moving to the new stack means a fresh install:
+
+1. In the Package Manager, add Wan2GP again under a different name. The installer won't accept a name that's already in use.
+2. Open **Advanced Options** and check that **Python Version** is 3.11 (the default on RTX 20XX and newer).
+3. Once it's installed and working, copy your outputs and anything else you want to keep from the old install folder, then uninstall the old copy.
+
+### Driver check at launch
+
+CUDA 13 needs NVIDIA driver 580 or newer. Wan2GP's launch now runs the same check as ComfyUI and AI Toolkit: on an NVIDIA card other than GTX 10XX, if the driver's major version is below 580 and the venv's torch is a `+cu130` build, it prints a console warning recommending a driver update (or an older torch index such as cu128) and stops the launch. Python 3.10 installs, and systems where the driver version can't be read, launch unchecked.
+
+## Package Environment Variables No Longer Silently Override Your Settings
+
+Several packages set their own workaround environment variables (most commonly `SETUPTOOLS_USE_DISTUTILS`, which keeps `distutils` importable for setuptools-based builds) directly on the venv process, after it had already been seeded with whatever you'd configured under **Settings → Environment Variables**. A value you set yourself was silently clobbered the moment the package launched.
+
+This surfaced on Wan2GP: it forced `SETUPTOOLS_USE_DISTUTILS=stdlib` on every launch, which crashes with `ModuleNotFoundError: No module named 'distutils'` on Python 3.12+, where `distutils` is gone from the standard library, and no user-set override could survive to work around it. The same unconditional force existed in AI Toolkit's SageAttention source build and VladAutomatic's Zluda/Rocm paths, though those already had a Python-version guard Wan2GP was missing.
+
+Two changes:
+
+- **User env vars win.** `Settings.IsEnvironmentVariableUserOverridden(key)` checks the *actual* configured environment variables (list or legacy dict format, `IsEnabled` respected), independent of what a package's own code has already written into the in-progress environment dictionary. A new `env.SetPackageDefault(settingsManager, key, value)` extension applies a package's computed default only when that check comes back false, and replaces every unconditional `SETUPTOOLS_USE_DISTUTILS` write (Wan2GP, AI Toolkit, VladAutomatic, and PipWheelService's SageAttention build) along with `Cogstudio`'s `UV_SKIP_WHEEL_FILENAME_CHECK` and `Reforge`/`A3WebUI`'s `STABLE_DIFFUSION_REPO`. Because the check is against your real settings rather than "is this key currently present," a package can still legitimately override its own base class's default (Reforge's Stable Diffusion repo URL over A3WebUI's) without that being mistaken for a user override.
+- **`SETUPTOOLS_USE_DISTUTILS` is version-gated everywhere.** Wan2GP now picks `local` on Python 3.12+ and `stdlib` below it, matching the guard AI Toolkit already carried (`local` uses setuptools' own vendored `distutils` copy, which works on any Python version; `stdlib` requires the real module to exist). PipWheelService's SageAttention build gained the same `Minor < 12` guard VladAutomatic already had, since it previously set the workaround unconditionally on every Python version.
+
+Cogstudio's temporary `UV_SKIP_WHEEL_FILENAME_CHECK` override (needed for one specific pinned wheel with a mismatched internal version) also had a `finally` block that unconditionally removed the variable, wiping out a value you'd set yourself. That's now a reusable helper, `venvRunner.RunWithTemporaryEnvironmentVariableAsync(key, value, action)`, which snapshots whatever was there before the override and restores exactly that when the action completes or throws.
+
+Deliberately out of scope: install-path-derived variables like `INVOKEAI_ROOT`, `COMFYUI_PATH`, `COMFYUI_MODEL_PATH`, `UV_BUILD_CONSTRAINT`, and `PIP_CONSTRAINT`. Those record facts about the install rather than tunable workarounds, so letting a user override them would just break the launch rather than fix anything.
+
+## Node.js Extraction on Linux and macOS
+
+On Linux and macOS, AI Toolkit's Node.js prerequisite comes from the official v20.19.3 `.tar.gz`, which contains relative symlinks for `npm`, `npx` and `corepack`. The bundled 7-Zip rejects those as dangerous link paths, so AI Toolkit installs on Linux failed before Python setup started ([#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767)).
+
+Rather than turning 7-Zip's protection off, the Unix Node install now goes through a small dedicated extractor built on .NET's `System.Formats.Tar`. It extracts directories and regular files first, keeping their Unix permissions so `node` stays executable, and creates the symlinks only after every file is out, so no archive entry can write through a link the same archive created. Each link is written as a relative link to its checked, normalized target. Extraction fails on:
+
+- absolute paths, backslash or drive/UNC spellings, and any path that resolves outside the destination;
+- a link whose target is not a regular file extracted from the same archive, which also rules out directory links, link chains and missing targets;
+- hard links and special entries such as devices and FIFOs;
+- an existing symlink or junction anywhere along a destination path.
+
+Windows Node installs and every other archive extraction keep their existing behavior.
+
+## Shared Model Folder Links Hiding DiffusionModels
+
+Every SwarmUI install gets `Models/diffusion_models`, a link to `Models/DiffusionModels`. Since 2.16.4 the model index follows links but scans each physical folder at most once (see [Workflow Library Link Loops](2.16.4.md#workflow-library-link-loops)), so the alias and the real folder competed for one folder, and the scan order let the last-listed entry win. On NTFS `_` sorts after uppercase letters, so `diffusion_models` won every time. Diffusion models were then indexed only under the alias, whose name maps to no model type, so the Checkpoint Manager showed bare filenames without previews or metadata, and Image Lab and Inference missed them ([#1749](https://github.com/LykosAI/StabilityMatrix/issues/1749), [#1753](https://github.com/LykosAI/StabilityMatrix/issues/1753), [#1755](https://github.com/LykosAI/StabilityMatrix/issues/1755)).
+
+The rule now: the real directory tree is scanned first, then links are followed in order of how many links each path passes through, and the first path to reach a physical folder owns it. A real folder is the zero-link case, so it always beats an alias. If `DiffusionModels` is itself a link to another drive and the alias points at that link, the direct link is one hop and the alias two, so name sort order (or Linux's unordered listing) never decides. Links are resolved one at a time through each stored target, which is what counts the hops and also stops resolution passing through `subst` drive letters, where every file had been listed twice.
+
+Not covered: a hand-made link whose stored target uses a different drive alias (`T:\...` while the library is reached through `C:\...`) can still list files twice. Stability Matrix's own links are spelled from the models folder, so they can't hit this.
+
+Diagnosed and first fixed by [@NeuralFault](https://github.com/NeuralFault) ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743)), with the link-count ordering by [@ionite34](https://github.com/ionite34) ([#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)).
+
+## Uninstall Size with Symlink Sharing
+
+For a package using symlink sharing, the package size shown in the uninstall confirmation (added in 2.16.4) could be the size of the whole shared model library. The count skipped links only among the package folder's direct children, then recursed with options that follow nested links, and symlink sharing puts its links one level down (`models/checkpoints`). It's now one enumeration that skips links at every depth and still counts hidden files, shared with the size shown when installing over an existing folder. Only the number was wrong: the delete checks for links at every level and only unlinks them, so shared models were never removed.
+
+## Where Model Categories Are Stored
+
+Model categories live in each model's existing `.cm-info.json`, in a `User` block holding the category names (plus a flag for models you disconnected from their source). Membership travels with the file through moves, renames and folder copies; settings hold only each category's name and colour.
+
+Every `.cm-info.json` write goes through one save path that carries the `User` block already on disk across when the data being written has none, so downloads and metadata refreshes can't drop your categories. Since a category alone creates a sidecar, "linked to a source" now means a CivitAI model id, an OpenModelDb or CivArchive source, or a HuggingFace source URL, not just having a `.cm-info.json`. Bulk **Find Connected Metadata** scans every unlinked model except ones you disconnected.
+
+## Thumbnail Cache for Local Images
+
+Checkpoint Manager cards had no image cache and the Outputs grid keeps only 64 decoded images in memory, so scrolling back decoded the full source file again. Local image loads now go through an on-disk cache of downscaled WebP thumbnails in `StabilityMatrix/Cache/Thumbnails` under the system temp folder.
+
+- A thumbnail's key covers the source's path, size, last-write time and the requested width, so an edited or replaced file gets a new one. Images no wider than the requested width aren't stored.
+- A thumbnail that won't decode is deleted and rebuilt.
+- The first image load of each session starts a background sweep that deletes thumbnails nobody has shown for 30 days, which keeps thumbnails of deleted outputs from piling up.
+- Source files are never modified. Preview images and outputs stay full resolution, and anything that asks for the full image still decodes the original.
+
+The decode width is now in device-independent pixels times the window's display scaling, so thumbnails are sharp at 150% and 200%. At 200% each decoded thumbnail has four times the pixels, so the Outputs memory cache can use up to four times the memory; at 100% nothing changes. On 40 real outputs averaging 2.8 MB, a cached 600 px thumbnail loaded in 3.4 ms against 113 ms for a full decode, at about 46 KB on disk each.