Files
LlamaCasty/ytLive.Tests/InstanceIsolationTests.cs
T
gramps 967df83925 tests: one source-assertion helper, so the prose trap can't recur
The trap I hit twice today — a file-wide DoesNotContain matching the doc
comment that explains the ban — was possible because three tests each
hand-rolled their own File.ReadAllText + path-walker, two of them mine. Fixed
at the root rather than per-test: SourceAssert now owns path walking and
literal extraction, with Literal(memberName, params parts) and Scopes() that
assert on the parsed VALUE. Tests that genuinely assert structure (the #if
DEBUG arm in InstanceIsolationTests, the absence of a whole API in the
locator) still read the file themselves, because those claims cannot be
reduced to a value — the helper is for literal facts only.

Shipping the fix introduced two defects, both caught by the build: a method
named Path shadowed System.IO.Path and broke the error message that names the
missing file, and Literal(fileName, memberName) invited a call that silently
resolved to a nonexistent path. Argument order is now required-context-first
so a wrong path is visible at the call site.

Suite 380/380, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag flaked
once mid-session with no ytLive instance running and passed in isolation and
on re-run — the environment flake already recorded in HANDOFF.md, not a
regression from this change.
2026-09-28 11:53:52 -07:00

229 lines
9.7 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 = SourceAssert.Text("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);
}
}