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.
229 lines
9.7 KiB
C#
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);
|
|
}
|
|
|
|
}
|