feat: Add asset loading functionality and related tests for improved resource management

This commit is contained in:
Neil Dorin 2026-04-09 14:00:27 -06:00
parent 4ed5e648c0
commit b85f1dab6b
8 changed files with 733 additions and 197 deletions

View file

@ -0,0 +1,334 @@
using System.IO.Compression;
using FluentAssertions;
using Xunit;
namespace PepperDash.Essentials.Tests.ControlSystem;
/// <summary>
/// Tests for <see cref="AssetLoader.Load"/>.
/// <see cref="AssetLoader"/> is the <c>System.IO</c>-based implementation that backs
/// <see cref="AssetLoader.Load"/>. Tests run against a
/// temporary directory tree so no Crestron runtime is required.
/// Debug is initialised with fakes via TestInitializer.
/// </summary>
public sealed class LoadAssetsTests : IDisposable
{
// ---------------------------------------------------------------------------
// Fixture: each test gets an isolated temp directory tree
//
// _rootDir/
// appdir/ ← applicationDirectoryPath (where the loader scans)
// user/
// program1/ ← filePathPrefix (where assets land)
// ---------------------------------------------------------------------------
private readonly string _rootDir;
private readonly string _appDir;
private readonly string _filePathPrefix;
public LoadAssetsTests()
{
_rootDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
_appDir = Path.Combine(_rootDir, "appdir");
_filePathPrefix = Path.Combine(_rootDir, "user", "program1") + Path.DirectorySeparatorChar;
Directory.CreateDirectory(_appDir);
Directory.CreateDirectory(_filePathPrefix);
}
public void Dispose()
{
if (Directory.Exists(_rootDir))
Directory.Delete(_rootDir, recursive: true);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static byte[] EmptyZip()
{
using var ms = new MemoryStream();
using (var _ = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
return ms.ToArray();
}
private static byte[] ZipWithFile(string entryName, string content = "test")
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var sw = new StreamWriter(entry.Open());
sw.Write(content);
}
return ms.ToArray();
}
private static byte[] ZipWithDirectory(string directoryEntryName)
{
// Directory entries have a trailing slash and an empty Name
var normalised = directoryEntryName.TrimEnd('/') + '/';
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
archive.CreateEntry(normalised);
}
return ms.ToArray();
}
private static byte[] ZipWithTraversalEntry()
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("../traversal.txt");
using var sw = new StreamWriter(entry.Open());
sw.Write("should not appear");
}
return ms.ToArray();
}
private void WriteToAppDir(string fileName, byte[] contents) =>
File.WriteAllBytes(Path.Combine(_appDir, fileName), contents);
private void WriteTextToAppDir(string fileName, string text) =>
File.WriteAllText(Path.Combine(_appDir, fileName), text);
// ---------------------------------------------------------------------------
// No-op cases — nothing in the application directory
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_EmptyApplicationDirectory_DoesNotThrow()
{
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().NotThrow();
}
// ---------------------------------------------------------------------------
// assets*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleAssetsZips_ThrowsException()
{
WriteToAppDir("assets1.zip", EmptyZip());
WriteToAppDir("assets2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple assets zip files*");
}
[Fact]
public void LoadAssets_SingleAssetsZip_ExtractsFileToFilePathPrefix()
{
WriteToAppDir("assets.zip", ZipWithFile("config.json", "{}"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(Path.Combine(_filePathPrefix, "config.json")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleAssetsZip_FileContentsArePreserved()
{
const string expected = "{\"key\":\"value\"}";
WriteToAppDir("assets.zip", ZipWithFile("data.json", expected));
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, "data.json")).Should().Be(expected);
}
[Fact]
public void LoadAssets_SingleAssetsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "assets.zip");
WriteToAppDir("assets.zip", ZipWithFile("file.txt"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse("assets zip should be cleaned up after extraction");
}
[Fact]
public void LoadAssets_AssetsZipWithDirectoryEntry_CreatesDirectory()
{
WriteToAppDir("assets.zip", ZipWithDirectory("subdir/"));
AssetLoader.Load(_appDir, _filePathPrefix);
Directory.Exists(Path.Combine(_filePathPrefix, "subdir/")).Should().BeTrue();
}
[Fact]
public void LoadAssets_AssetsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("assets.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
// ---------------------------------------------------------------------------
// htmlassets*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleHtmlAssetsZips_ThrowsException()
{
WriteToAppDir("htmlassets1.zip", EmptyZip());
WriteToAppDir("htmlassets2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple htmlassets zip files*");
}
[Fact]
public void LoadAssets_SingleHtmlAssetsZip_ExtractsToHtmlDirectory()
{
// htmlDir = rootDir/html
WriteToAppDir("htmlassets.zip", ZipWithFile("index.html", "<html/>"));
AssetLoader.Load(_appDir, _filePathPrefix);
var expectedHtmlDir = Path.Combine(_rootDir, "html");
File.Exists(Path.Combine(expectedHtmlDir, "index.html")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleHtmlAssetsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "htmlassets.zip");
WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse();
}
[Fact]
public void LoadAssets_HtmlAssetsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("htmlassets.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
[Fact]
public void LoadAssets_HtmlAssetsZip_ShallowFilePathPrefixThrowsWhenRootCannotBeDetermined()
{
// A path that is exactly ONE level below the filesystem root has no grandparent,
// so rootDir (programDir.Parent.Parent) is null and the guard should throw.
// DirectoryInfo works with non-existent paths, so we don't create the directory.
var filesystemRoot = Path.GetPathRoot(Path.GetTempPath())!;
var shallowPrefix = Path.Combine(filesystemRoot, "pepperDashTestShallow") + Path.DirectorySeparatorChar;
WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
var act = () => AssetLoader.Load(_appDir, shallowPrefix);
act.Should().Throw<Exception>().WithMessage("*Unable to determine root directory for html extraction*");
}
// ---------------------------------------------------------------------------
// essentials-devtools*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleDevToolsZips_ThrowsException()
{
WriteToAppDir("essentials-devtools1.zip", EmptyZip());
WriteToAppDir("essentials-devtools2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple essentials-devtools zip files*");
}
[Fact]
public void LoadAssets_SingleDevToolsZip_ExtractsToHtmlDebugDirectory()
{
// debugDir = rootDir/html/debug
WriteToAppDir("essentials-devtools.zip", ZipWithFile("app.js", "console.log('hi');"));
AssetLoader.Load(_appDir, _filePathPrefix);
var expectedDebugDir = Path.Combine(_rootDir, "html", "debug");
File.Exists(Path.Combine(expectedDebugDir, "app.js")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleDevToolsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "essentials-devtools.zip");
WriteToAppDir("essentials-devtools.zip", ZipWithFile("tool.js"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse();
}
[Fact]
public void LoadAssets_DevToolsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("essentials-devtools.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
// ---------------------------------------------------------------------------
// *configurationFile*.json
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleJsonConfigFiles_ThrowsException()
{
WriteTextToAppDir("abcconfigurationFile1.json", "{}");
WriteTextToAppDir("abcconfigurationFile2.json", "{}");
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple configuration files found*");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_IsMovedToFilePathPrefix()
{
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, "{}");
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(Path.Combine(_appDir, fileName)).Should().BeFalse("source file should be moved, not copied");
File.Exists(Path.Combine(_filePathPrefix, fileName)).Should().BeTrue("file should exist at the file path prefix");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_ExistingDestinationIsReplaced()
{
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, "new content");
// Pre-populate the destination with stale content
File.WriteAllText(Path.Combine(_filePathPrefix, fileName), "old content");
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be("new content");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_ContentIsPreserved()
{
const string content = "{\"devices\":[]}";
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, content);
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be(content);
}
}

View file

@ -0,0 +1,61 @@
using PepperDash.Core.Abstractions;
namespace PepperDash.Essentials.Tests.Fakes;
internal class FakeCrestronEnvironment : ICrestronEnvironment
{
public DevicePlatform DevicePlatform { get; set; } = DevicePlatform.Appliance;
public RuntimeEnvironment RuntimeEnvironment { get; set; } = RuntimeEnvironment.SimplSharpPro;
public string NewLine { get; set; } = "\r\n";
public uint ApplicationNumber { get; set; } = 1;
public uint RoomId { get; set; } = 0;
public event EventHandler<ProgramStatusEventArgs>? ProgramStatusChanged
{
add { }
remove { }
}
public event EventHandler<PepperDashEthernetEventArgs>? EthernetEventReceived
{
add { }
remove { }
}
public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath();
public bool IsHardwareRuntime => false;
}
internal class NoOpCrestronConsole : ICrestronConsole
{
public void PrintLine(string message) { }
public void Print(string message) { }
public void ConsoleCommandResponse(string message) { }
public void AddNewConsoleCommand(Action<string> _, string __, string ___, ConsoleAccessLevel ____) { }
}
internal class InMemoryCrestronDataStore : ICrestronDataStore
{
private readonly Dictionary<string, object> _store = new();
public void InitStore() { }
public bool TryGetLocalInt(string key, out int value)
{
if (_store.TryGetValue(key, out var raw) && raw is int i) { value = i; return true; }
value = 0;
return false;
}
public bool SetLocalInt(string key, int value) { _store[key] = value; return true; }
public bool SetLocalUint(string key, uint value) { _store[key] = (int)value; return true; }
public bool TryGetLocalBool(string key, out bool value)
{
if (_store.TryGetValue(key, out var raw) && raw is bool b) { value = b; return true; }
value = false;
return false;
}
public bool SetLocalBool(string key, bool value) { _store[key] = value; return true; }
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<!-- Provides access to ControlSystem.LoadAssets (internal) after InternalsVisibleTo is set -->
<ProjectReference Include="..\PepperDash.Essentials\PepperDash.Essentials.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,28 @@
using System.Runtime.CompilerServices;
using PepperDash.Core.Abstractions;
using PepperDash.Essentials.Tests.Fakes;
namespace PepperDash.Essentials.Tests;
/// <summary>
/// Runs once before any type in this assembly is accessed.
/// Registers fake Crestron service implementations so that the <c>Debug</c> static
/// constructor never tries to reach the real Crestron SDK.
/// </summary>
internal static class TestInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
DebugServiceRegistration.Register(
new FakeCrestronEnvironment
{
DevicePlatform = DevicePlatform.Server,
RuntimeEnvironment = RuntimeEnvironment.Other,
},
new NoOpCrestronConsole(),
new InMemoryCrestronDataStore());
}
}