Files
LlamaCasty/ytLive.Tests/InstanceIsolationTests.cs
gramps de81fa3c5d dev-only: per-instance state so two LlamaCasty instances can run side by side
Pre-1.0 the creator streams in one instance and screen-captures it from
another. Nothing prevented a second instance - there is no single-instance
mutex and no port anywhere. What broke it was SHARED STATE, and two of the
collisions were hard failures rather than annoyances:

- the layout DB. The model is read-whole-scene / write-whole-scene, so two
  instances saving different layouts clobber each other.
- the WebView2 user data folder. Chromium takes an exclusive lock on it, so
  the second instance of the same exe does not start at all.
- the auth token store. A test instance would overwrite the real YouTube
  sign-in with its own.
- startup.log, where two appenders interleave and a crash in either instance
  becomes ambiguous.

Set YTLIVE_INSTANCE=<id> and the process gets a private root at
%APPDATA%\ytLlive\instances/<id>/ for those four. Recording folder and the
ffmpeg tools cache stay shared on purpose - the cache should be shared, and
the creator picks the record folder. Global hotkeys stay un-namespaced: if
both instances register the same one, Windows refusing the second is the
correct answer.

An id that is not letters/digits/dash/underscore is rejected and degrades to
the primary profile, so the variable can never walk out of the profile
directory or name a UNC path.

The entire implementation is inside #if DEBUG. A Release build compiles to
DataRoot => DefaultRoot and WebViewDataFolder => null, and the call sites are
unconditional so Release cannot drift by forgetting an #if. The harness lives
with the tests: InstanceIsolationTests covers the two-identities contract,
the primary-instance no-op, per-instance WebView folders, path-traversal
rejection, that the real output paths actually move with the profile, and a
source-level assertion that the #else arm IS production behaviour.

Verified against a real Release build: the InstanceVariable field is absent
from its metadata and no "instances" path segment survives, while DataRoot
and WebViewDataFolder are present in both configurations. Grepping for
YTLIVE_INSTANCE proves nothing - a const is inlined at compile time and
appears in neither build, which cost one wasted verification round.

Docs for this unit (the InstanceProfile paragraph in ai.md, the 1.0 gate in
TASKS.md, and the MyMistakes/HANDOFF entries) landed in the previous commit,
because they share those files with the branding-credit work.
2026-09-27 08:56:12 -07:00

241 lines
10 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Linq;
using System.Reflection;
using Xunit;
using ytLive.Helpers;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Good Dog (2026-09-26): in DEBUG, two LlamaCasty processes must be able to run side by
/// side without fighting over state, because pre-1.0 the creator streams in one and
/// screen-captures it from the other.
/// <para>Nothing in the app prevented a second instance — there is no single-instance
/// mutex and no port. What broke it was SHARED STATE, in two ways that are hard
/// failures rather than annoyances: the whole-scene read/write layout DB (two instances
/// clobber each other's saves) and Chromium's exclusive lock on the WebView2 user data
/// folder (the second instance of the same exe does not start).</para>
/// <para>These facts live with the tests on purpose. The app's side is a
/// <c>#if DEBUG</c> block in <see cref="InstanceProfile"/>; this file is the harness that
/// exercises it, and — the part that matters at 1.0 — the assertion that a Release
/// build cannot possibly contain it.</para>
/// </summary>
public sealed class InstanceIsolationTests
{
// ---------- two identities get two private roots ----------
[Fact]
public void TwoInstances_EachGetTheirOwnLayoutDatabase()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
var roots = new string[2];
for (var i = 0; i < 2; i++)
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, $"{i + 1}");
roots[i] = InstanceProfile.DataRoot;
}
Assert.NotEqual(roots[0], roots[1]);
foreach (var root in roots)
Assert.NotEqual(InstanceProfile.DefaultRoot, root);
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
[Fact]
public void PrimaryInstance_StillUsesTheRealUserProfile()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
// Unset: the everyday case must be byte-for-byte what it always was. This is
// the fact that stops the dev affordance from leaking into normal use.
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, null);
Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot);
Assert.Null(InstanceProfile.WebViewDataFolder);
// Blank and whitespace are "no instance", not an instance called "".
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, " ");
Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot);
Assert.Null(InstanceProfile.WebViewDataFolder);
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
[Fact]
public void EachInstance_GetsItsOwnWebViewDataFolder()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "a");
var a = InstanceProfile.WebViewDataFolder;
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "b");
var b = InstanceProfile.WebViewDataFolder;
// Chromium locks this folder exclusively — a shared value means the second
// instance fails to launch, so it is not a nice-to-have.
Assert.NotNull(a);
Assert.NotNull(b);
Assert.NotEqual(a, b);
Assert.Contains("a", a!);
Assert.Contains("b", b!);
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
[Fact]
public void AMaliciousOrMistypedId_CannotEscapeTheProfileFolder()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
foreach (var hostile in new[]
{
"..\\..\\Windows", "..", "a/b", "a\\b", "C:evil", "with space", "a;b", "*",
})
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, hostile);
// A rejected id degrades to the primary profile — it never builds a path
// outside %APPDATA%\ytLlive, and never throws.
Assert.Equal(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot);
Assert.Null(InstanceProfile.WebViewDataFolder);
}
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
[Fact]
public void ALegitimateId_AllowsLettersDigitsDashAndUnderscore()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "live_2-b");
Assert.Equal("live_2-b", InstanceProfile.Id);
Assert.NotEqual(InstanceProfile.DefaultRoot, InstanceProfile.DataRoot);
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
// ---------- the app's real outputs actually move with the profile ----------
[Fact]
public void TheRealOutputPaths_FollowTheInstanceProfile()
{
var previous = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable);
try
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "7");
// These are the exact expressions the app uses at startup. If any of them
// stops consulting InstanceProfile, two dev instances start clobbering
// each other again and nothing else in the suite would notice.
var layout = Path.Combine(InstanceProfile.DataRoot, "ytLlive.db");
var auth = TokenStore.DefaultPath;
Assert.Equal(Path.Combine(InstanceProfile.DataRoot, "ytLlive.auth"), auth);
Assert.Contains("instances", layout);
Assert.Contains("instances", auth);
}
finally
{
Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, previous);
}
}
// ---------- the 1.0 gate ----------
[Fact]
public void InstanceProfile_ExposesNoThirdPathForACallerToDiscover()
{
// Every member exists in every configuration — the call sites are unconditional,
// so Release cannot drift by forgetting an #if — and there are only these three.
var properties = typeof(InstanceProfile)
.GetProperties(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(p => p.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
// The dev surface (Id) exists only in a DEBUG build, by design; the three
// production members exist in both, which is what keeps the call sites
// unconditional. Pin both shapes so neither can change unnoticed.
#if DEBUG
Assert.Equal(new[] { "DataRoot", "DefaultRoot", "Id", "WebViewDataFolder" }, properties);
#else
Assert.Equal(new[] { "DataRoot", "DefaultRoot", "WebViewDataFolder" }, properties);
#endif
// The primary instance must not perturb the everyday Chromium profile.
Assert.Null(typeof(InstanceProfile)
.GetProperty("WebViewDataFolder")!.GetValue(null));
}
[Fact]
public void TheAlternateProfile_IsConfinedToTheDebugBuild()
{
// The 1.0 promise is structural, so this asserts the structure: the alternate
// profile must live inside #if DEBUG and the #else arm must be the real user
// profile and nothing else. If someone ever hoists the implementation out of the
// DEBUG arm — or adds a second one — this fails, which is the point.
//
// Verified against the real Release binary too (2026-09-26): a Release build has
// no InstanceVariable field in metadata and no "instances" path segment, while
// DataRoot / WebViewDataFolder are present in both configurations. Note a
// const like YTLIVE_INSTANCE is INLINED at compile time and therefore never
// appears in either binary — grepping for the variable name proves nothing.
var source = File.ReadAllText(RepoFile("Helpers", "InstanceProfile.cs"));
// The whole promise is that a Release build compiles to the #else arm, so assert
// that arm IS production: the one real user profile, no alternate folder, and no
// environment variable. If the implementation is ever hoisted out of #if DEBUG,
// this fails — which is the entire point of having it.
var elseAt = source.IndexOf("#else", StringComparison.Ordinal);
var endifAt = source.IndexOf("#endif", StringComparison.Ordinal);
Assert.True(elseAt > 0 && endifAt > elseAt,
"InstanceProfile.cs has no #else/#endif arm — the Release behaviour is no longer explicit");
var releaseArm = source.Substring(elseAt, endifAt - elseAt);
Assert.Contains("DefaultRoot", releaseArm);
Assert.DoesNotContain("instances", releaseArm);
Assert.DoesNotContain("Environment", releaseArm);
Assert.DoesNotContain("Sanitize", releaseArm);
// And the development behaviour really is in the DEBUG arm above it.
var debugArm = source.Substring(0, elseAt);
Assert.Contains("#if DEBUG", debugArm);
Assert.Contains("instances", debugArm);
}
private static string RepoFile(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(
$"could not locate {Path.Combine(parts)} above {AppContext.BaseDirectory}");
}
}