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; /// /// 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. /// 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). /// These facts live with the tests on purpose. The app's side is a /// #if DEBUG block in ; 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. /// 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}"); } }