Files
LlamaCasty/ytLive.Tests/StorePackagingTests.cs
T
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -08:00

141 lines
7.5 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Xunit;
namespace ytLive.Tests;
/// <summary>TASK 48, slice 3 — the creator's ruling (2026-09-27): the app ships as a
/// **Microsoft Store MSIX** and the **Store updates it**. The self-update subsystem that
/// existed for the abandoned self-hosted route (a Velopack bootstrap plus the DigitalOcean
/// update-URL droplet) is therefore deleted, not parked: a second updater inside a
/// Store-installed package fights the Store's own updater.
///
/// <para>This test is the guard: the app project references no updater package and no app
/// source file uses the updater API — so the deleted subsystem cannot creep back in, and
/// the "the Store updates it" decision stays the only update path.</para></summary>
public class StorePackagingTests
{
/// <summary>Known self-update packages. If one of these comes back, the Store route has
/// been quietly reversed.</summary>
private static readonly string[] UpdaterPackages =
{
"Velopack", "Squirrel", "NetSparkle", "AutoUpdater", "WinSparkle"
};
[Fact]
public void The_app_ships_no_self_update_subsystem()
{
// 1. The app project references no updater package.
var project = XDocument.Load(SourceAssert.RepoPath("ytLive.csproj"));
var packages = project.Descendants()
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => (string?)e.Attribute("Include"))
.Where(name => name != null)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var updater in UpdaterPackages)
Assert.DoesNotContain(updater, packages);
// 2. No app source file uses the updater API. Matched as CODE (a using directive,
// a type usage), not as a bare word — a file's prose is allowed to name what it
// deliberately does not do.
var root = Path.GetDirectoryName(SourceAssert.RepoPath("ytLive.csproj"))!;
foreach (var file in AppSourceFiles(root))
{
var text = File.ReadAllText(file);
Assert.False(
Regex.IsMatch(text, @"^\s*using\s+Velopack\s*;", RegexOptions.Multiline),
$"{Path.GetFileName(file)} still imports the Velopack updater — the Store updates this app.");
Assert.False(
Regex.IsMatch(text, @"\bVelopackApp\b"),
$"{Path.GetFileName(file)} still calls the Velopack updater — the Store updates this app.");
}
}
/// <summary>Every C# file of the app project (not the test project, not build output).</summary>
private static IEnumerable<string> AppSourceFiles(string root)
{
return Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}ytLive.Tests{Path.DirectorySeparatorChar}"))
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
.Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
}
private static readonly XNamespace Foundation =
"http://schemas.microsoft.com/appx/manifest/foundation/windows10";
private static readonly XNamespace Uap =
"http://schemas.microsoft.com/appx/manifest/uap/windows10";
private static readonly XNamespace Uap10 =
"http://schemas.microsoft.com/appx/manifest/uap/windows10/10";
private static readonly XNamespace Rescap =
"http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities";
/// <summary>TASK 48, slice 2 — the MSIX manifest. This is the file that decides whether
/// recording, capturing and the bundled ffmpeg still work once the app is packaged:
/// a full-trust packagedClassicApp at mediumIL can launch ffmpeg as a child process and
/// write to the user's profile; an AppContainer cannot, and recording would silently
/// land in a virtualised folder. So the guard pins every property that makes that true,
/// plus the x64/en-US decisions and the logo files the packer requires.</summary>
[Fact]
public void The_package_manifest_declares_full_trust_camera_and_microphone()
{
var manifestPath = SourceAssert.RepoPath(Path.Combine("Packaging", "Package.appxmanifest"));
Assert.True(File.Exists(manifestPath), "Packaging/Package.appxmanifest is missing.");
var manifest = XDocument.Load(manifestPath);
var package = manifest.Root!;
Assert.Equal(Foundation + "Package", package.Name);
// 1. Identity: x64 only (creator ruling 2026-10-09), and the title the Store expects.
var identity = package.Element(Foundation + "Identity")!;
Assert.Equal("LlamaCasty", (string?)identity.Attribute("Name"));
Assert.Equal("x64", (string?)identity.Attribute("ProcessorArchitecture"));
// 2. Full trust: packagedClassicApp + mediumIL + the restricted runFullTrust capability.
var application = package.Element(Foundation + "Applications")!
.Element(Foundation + "Application")!;
Assert.Equal("Windows.FullTrustApplication", (string?)application.Attribute("EntryPoint"));
Assert.Equal("LlamaCasty.exe", (string?)application.Attribute("Executable"));
Assert.Equal("mediumIL", (string?)application.Attribute(Uap10 + "TrustLevel"));
Assert.Equal("packagedClassicApp", (string?)application.Attribute(Uap10 + "RuntimeBehavior"));
var capabilities = package.Element(Foundation + "Capabilities")!;
Assert.Contains(capabilities.Elements(Rescap + "Capability"),
element => (string?)element.Attribute("Name") == "runFullTrust");
var devices = capabilities.Elements(Foundation + "DeviceCapability")
.Select(element => (string?)element.Attribute("Name"))
.ToArray();
Assert.Contains("webcam", devices);
Assert.Contains("microphone", devices);
// 3. ⛔ Never appContainer — that is the change that breaks recording (item 6).
// Matched on ATTRIBUTE VALUES, not the file text: the manifest's own comment
// explains why it must not be appContainer, and prose is allowed to say so.
Assert.DoesNotContain(
manifest.Descendants()
.SelectMany(element => element.Attributes())
.Select(attribute => attribute.Value),
value => string.Equals(value, "appContainer", StringComparison.OrdinalIgnoreCase));
// 4. English only (policy 10.7): a second language forces the listing to be localised.
var languages = package.Element(Foundation + "Resources")!
.Elements(Foundation + "Resource")
.Select(element => (string?)element.Attribute("Language"))
.ToArray();
Assert.Equal(new[] { "en-US" }, languages);
// 5. Every image the manifest references is actually in the package source tree —
// makeappx refuses to pack otherwise.
var packaging = Path.GetDirectoryName(manifestPath)!;
foreach (var logo in manifest.Descendants()
.SelectMany(element => element.Attributes())
.Where(attribute => attribute.Name.LocalName.EndsWith("Logo", StringComparison.Ordinal))
.Select(attribute => attribute.Value.Replace('\\', Path.DirectorySeparatorChar)))
{
Assert.True(File.Exists(Path.Combine(packaging, logo)), $"manifest references a missing logo: {logo}");
}
}
}