diff --git a/tests/GenWave.Host.Tests/Specs/Gh019_ScriptPreflight.cs b/tests/GenWave.Host.Tests/Specs/Gh019_ScriptPreflight.cs index f545f3e5..1e054c66 100644 --- a/tests/GenWave.Host.Tests/Specs/Gh019_ScriptPreflight.cs +++ b/tests/GenWave.Host.Tests/Specs/Gh019_ScriptPreflight.cs @@ -23,7 +23,7 @@ public static class FeatureScriptPreflight static string WriteEnvFile(params string[] assignments) { - var path = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, assignments); return path; } diff --git a/tests/GenWave.Host.Tests/Specs/Gh332_PinnedFailurePathShowsStoppedContainers.cs b/tests/GenWave.Host.Tests/Specs/Gh332_PinnedFailurePathShowsStoppedContainers.cs index 5f7da0b0..b1c88391 100644 --- a/tests/GenWave.Host.Tests/Specs/Gh332_PinnedFailurePathShowsStoppedContainers.cs +++ b/tests/GenWave.Host.Tests/Specs/Gh332_PinnedFailurePathShowsStoppedContainers.cs @@ -62,7 +62,7 @@ static string WriteEnvFile() => static string WriteEnvFile(string[] assignments) { - var path = Path.Combine(Directory.CreateTempSubdirectory("gh332-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, assignments); return path; } @@ -72,7 +72,7 @@ sealed record Run(int ExitCode, string StdOut, string StdErr, string[] DockerCal static Run RunPinnedLaunch() { var bin = MakeBinDirWithDockerStub(); - var log = Path.Combine(Directory.CreateTempSubdirectory("gh332-log-").FullName, "docker.log"); + var log = Path.Combine(TempDir.CreateForProcessLifetime(), "docker.log"); var (exitCode, stdOut, stdErr) = ScriptProcess.Run( "launch.sh", bin, envFile: WriteEnvFile(), diff --git a/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs b/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs index 76c4c552..c4d2c210 100644 --- a/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs +++ b/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs @@ -13,6 +13,7 @@ using GenWave.Core.Domain; using GenWave.Core.Playout; using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; using GenWave.Orchestration; using GenWave.Tts; @@ -199,16 +200,16 @@ public sealed class ScenarioSynthesizerAlwaysFailsOrchestratorYieldsMusic : IAsyncLifetime { KokoroStubServer stub = null!; - DirectoryInfo cacheDir = null!; + TempDir cacheDir = null!; Orchestrator orchestrator = null!; public async Task InitializeAsync() { stub = await KokoroStubServer.StartAsync(KokoroStubMode.Fail500); - cacheDir = System.IO.Directory.CreateTempSubdirectory("genwave-t015-fail-"); + cacheDir = new TempDir(); orchestrator = BuildOrchestrator( stub.BaseUri, - cacheDir.FullName, + cacheDir.Path, voice: $"voice-{Guid.NewGuid():N}", renderBudget: TimeSpan.FromSeconds(2), catalog: new MultiTrackCatalog()); @@ -217,7 +218,7 @@ public async Task InitializeAsync() public async Task DisposeAsync() { await stub.DisposeAsync(); - if (cacheDir.Exists) cacheDir.Delete(recursive: true); + cacheDir.Dispose(); } [Fact] @@ -284,7 +285,7 @@ public sealed class ScenarioSynthesizerTimesOutOrchestratorYieldsMusic : IAsyncLifetime { KokoroStubServer stub = null!; - DirectoryInfo cacheDir = null!; + TempDir cacheDir = null!; Orchestrator orchestrator = null!; // Budget is 200 ms — well under the 30-second delay the stub introduces. @@ -293,10 +294,10 @@ public sealed class ScenarioSynthesizerTimesOutOrchestratorYieldsMusic public async Task InitializeAsync() { stub = await KokoroStubServer.StartAsync(KokoroStubMode.DelayPastBudget); - cacheDir = System.IO.Directory.CreateTempSubdirectory("genwave-t015-timeout-"); + cacheDir = new TempDir(); orchestrator = BuildOrchestrator( stub.BaseUri, - cacheDir.FullName, + cacheDir.Path, voice: $"voice-{Guid.NewGuid():N}", renderBudget: RenderBudget, catalog: new MultiTrackCatalog()); @@ -305,7 +306,7 @@ public async Task InitializeAsync() public async Task DisposeAsync() { await stub.DisposeAsync(); - if (cacheDir.Exists) cacheDir.Delete(recursive: true); + cacheDir.Dispose(); } [Fact] @@ -395,16 +396,16 @@ public sealed class ScenarioSynthesizerRecoveryTtsResumesOnceKokoroIsBack : IAsyncLifetime { KokoroStubServer stub = null!; - DirectoryInfo cacheDir = null!; + TempDir cacheDir = null!; Orchestrator orchestrator = null!; public async Task InitializeAsync() { stub = await KokoroStubServer.StartAsync(KokoroStubMode.Fail500); - cacheDir = System.IO.Directory.CreateTempSubdirectory("genwave-t015-recovery-"); + cacheDir = new TempDir(); orchestrator = BuildOrchestrator( stub.BaseUri, - cacheDir.FullName, + cacheDir.Path, // Unique voice ensures no stale cache hits from the Fail500 window. voice: $"voice-{Guid.NewGuid():N}", renderBudget: TimeSpan.FromSeconds(2), @@ -414,7 +415,7 @@ public async Task InitializeAsync() public async Task DisposeAsync() { await stub.DisposeAsync(); - if (cacheDir.Exists) cacheDir.Delete(recursive: true); + cacheDir.Dispose(); } [Fact] diff --git a/tests/GenWave.Host.Tests/Specs/Story013_AcceptanceGate02_LevelMatchingRealKokoro.cs b/tests/GenWave.Host.Tests/Specs/Story013_AcceptanceGate02_LevelMatchingRealKokoro.cs index 22c65222..63386417 100644 --- a/tests/GenWave.Host.Tests/Specs/Story013_AcceptanceGate02_LevelMatchingRealKokoro.cs +++ b/tests/GenWave.Host.Tests/Specs/Story013_AcceptanceGate02_LevelMatchingRealKokoro.cs @@ -21,6 +21,7 @@ // Alias to disambiguate the GenWave.Loudness namespace from the Loudness domain type. using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; using FfmpegAnalyzer = GenWave.Loudness.FfmpegLoudnessAnalyzer; using TrackLoudness = GenWave.Core.Domain.Loudness; @@ -135,7 +136,7 @@ static async Task RenderSegmentAsync(string cacheRoot, string kokoroB public sealed class ScenarioRenderedSegmentHasMeasuredLoudness : IAsyncLifetime { readonly KokoroFixture fixture; - DirectoryInfo cacheDir = null!; + TempDir cacheDir = null!; MediaItem renderedItem = null!; public ScenarioRenderedSegmentHasMeasuredLoudness(KokoroFixture fixture) @@ -145,14 +146,14 @@ public ScenarioRenderedSegmentHasMeasuredLoudness(KokoroFixture fixture) public async Task InitializeAsync() { - cacheDir = System.IO.Directory.CreateTempSubdirectory("genwave-t016-"); + cacheDir = new TempDir(); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - renderedItem = await RenderSegmentAsync(cacheDir.FullName, fixture.BaseUrl, cts.Token); + renderedItem = await RenderSegmentAsync(cacheDir.Path, fixture.BaseUrl, cts.Token); } public Task DisposeAsync() { - if (cacheDir.Exists) cacheDir.Delete(recursive: true); + cacheDir.Dispose(); return Task.CompletedTask; } diff --git a/tests/GenWave.Host.Tests/Specs/Story328_CrosstalkStockWorker.cs b/tests/GenWave.Host.Tests/Specs/Story328_CrosstalkStockWorker.cs index e59da2e5..2ef81657 100644 --- a/tests/GenWave.Host.Tests/Specs/Story328_CrosstalkStockWorker.cs +++ b/tests/GenWave.Host.Tests/Specs/Story328_CrosstalkStockWorker.cs @@ -255,21 +255,15 @@ public void Files_left_over_from_a_previous_run_are_deleted() // Given a crosstalk cache directory holding an asset an earlier, crashed/restarted // process assembled but never vended (SPEC F127.7's own "the stock survives nothing" — // PLAN T285's recorded rider: crosstalk/ has no other sweeper) - var dir = Directory.CreateTempSubdirectory("crosstalk-purge-test-").FullName; - try - { - var orphan = Path.Combine(dir, "orphan.wav"); - File.WriteAllBytes(orphan, [0]); - - CrosstalkStockWorker.PurgeStaleAssets(dir, NullLogger.Instance); - - // Then the orphaned asset is gone - Assert.False(File.Exists(orphan)); - } - finally - { - Directory.Delete(dir, recursive: true); - } + using var tmp = new TempDir(); + var dir = tmp.Path; + var orphan = Path.Combine(dir, "orphan.wav"); + File.WriteAllBytes(orphan, [0]); + + CrosstalkStockWorker.PurgeStaleAssets(dir, NullLogger.Instance); + + // Then the orphaned asset is gone + Assert.False(File.Exists(orphan)); } [Fact] diff --git a/tests/GenWave.Host.Tests/Specs/Story342_PreflightExpansion.cs b/tests/GenWave.Host.Tests/Specs/Story342_PreflightExpansion.cs index 5838c668..a338ccda 100644 --- a/tests/GenWave.Host.Tests/Specs/Story342_PreflightExpansion.cs +++ b/tests/GenWave.Host.Tests/Specs/Story342_PreflightExpansion.cs @@ -31,7 +31,7 @@ exit 0 static string WriteEnvFile(params string[] assignments) { - var path = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, assignments); return path; } @@ -51,7 +51,7 @@ static string CompleteEnvFile(string mediaDir, string? adminPasswordLine = null) /// A fresh scratch directory holding the given count of .flac/.mp3 files (and nothing else). static string MakeMediaDir(int flacCount = 0, int mp3Count = 0) { - var dir = Directory.CreateTempSubdirectory("gw-preflight-story342-media-").FullName; + var dir = TempDir.CreateForProcessLifetime(); for (var i = 0; i < flacCount; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.flac"), ""); for (var i = 0; i < mp3Count; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.mp3"), ""); return dir; @@ -73,8 +73,7 @@ static string MakeMediaDir(int flacCount = 0, int mp3Count = 0) string binDir, string? envFile = null, IReadOnlyDictionary? extraEnv = null, string script = DefaultPreflightScript) { - var scriptPath = Path.Combine( - Directory.CreateTempSubdirectory("gw-preflight-story342-script-").FullName, "run.sh"); + var scriptPath = Path.Combine(TempDir.CreateForProcessLifetime(), "run.sh"); File.WriteAllText(scriptPath, script); return ScriptProcess.Run(scriptPath, binDir, envFile, extraEnv); @@ -393,8 +392,8 @@ exit 0 echo "Filesystem 1024-blocks Used Available Capacity Mounted on" echo "tmpfs 2000000 1000000 900000 53% /" """); - var missingFallback = Path.Combine( - Directory.CreateTempSubdirectory("gw-preflight-story342-nodockerroot-").FullName, "does-not-exist"); + using var missingFallbackDir = new TempDir(); + var missingFallback = Path.Combine(missingFallbackDir.Path, "does-not-exist"); var envFile = CompleteEnvFile(MakeMediaDir(flacCount: 1)); var (_, stdOut, _) = RunPreflight( @@ -407,7 +406,8 @@ exit 0 [Fact] public void RamUnderTheFullTopologyConstantSuggestsPiperOnly() { - var meminfo = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-ram-").FullName, "meminfo"); + using var meminfoDir = new TempDir(); + var meminfo = Path.Combine(meminfoDir.Path, "meminfo"); File.WriteAllText(meminfo, "MemTotal: 3945000 kB\nMemFree: 100000 kB\n"); var envFile = CompleteEnvFile(MakeMediaDir(flacCount: 1)); @@ -423,7 +423,8 @@ public void PiWithoutCgroupMemoryWarnsThatMemLimitsAreDiscarded() { // cmdline.txt probe (test seam points at a scratch file) missing // cgroup_enable=memory → WARN with the HARDWARE.md pointer. - var cmdline = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-cmdline-").FullName, "cmdline.txt"); + using var cmdlineDir = new TempDir(); + var cmdline = Path.Combine(cmdlineDir.Path, "cmdline.txt"); File.WriteAllText(cmdline, "console=serial0,115200 root=PARTUUID=xyz rootfstype=ext4 rootwait\n"); var envFile = CompleteEnvFile(MakeMediaDir(flacCount: 1)); @@ -485,7 +486,8 @@ public void UppercaseExtensionFilesCountTowardTheTotal() public void AnNfsMediaDirPrintsTheStaleInodeAndCaseNotes() { var mediaDir = MakeMediaDir(flacCount: 1); - var mounts = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-mounts-").FullName, "mounts"); + using var mountsDir = new TempDir(); + var mounts = Path.Combine(mountsDir.Path, "mounts"); File.WriteAllLines(mounts, [ "/dev/sda1 / ext4 rw 0 0", @@ -508,7 +510,8 @@ public void ALocalMediaDirNamesTheDetectedFilesystemType() // determined (see the next fact). Capitalized and reworded to "Local disk (...)" // either way, matching the neighboring "NFS-mounted (...)" row's own capitalization. var mediaDir = MakeMediaDir(flacCount: 1); - var mounts = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-mounts-").FullName, "mounts"); + using var mountsDir = new TempDir(); + var mounts = Path.Combine(mountsDir.Path, "mounts"); File.WriteAllLines(mounts, [$"/dev/sda1 {mediaDir} ext4 rw 0 0"]); var envFile = CompleteEnvFile(mediaDir); @@ -526,7 +529,8 @@ public void AMediaDirWithNoMatchingMountEntrySaysTypeNotIdentifiedNotUnknown() // "local" itself read as though IT were unverified, when only the TYPE is. Honest // wording now, and the parenthetical stays reserved for an actual detected type. var mediaDir = MakeMediaDir(flacCount: 1); - var mounts = Path.Combine(Directory.CreateTempSubdirectory("gw-preflight-story342-mounts-").FullName, "mounts"); + using var mountsDir = new TempDir(); + var mounts = Path.Combine(mountsDir.Path, "mounts"); File.WriteAllLines(mounts, ["/dev/sda1 / ext4 rw 0 0"]); // no entry matching mediaDir var envFile = CompleteEnvFile(mediaDir); diff --git a/tests/GenWave.Host.Tests/Specs/Story343_StagedStartup.cs b/tests/GenWave.Host.Tests/Specs/Story343_StagedStartup.cs index 8fdcc574..9f008db2 100644 --- a/tests/GenWave.Host.Tests/Specs/Story343_StagedStartup.cs +++ b/tests/GenWave.Host.Tests/Specs/Story343_StagedStartup.cs @@ -97,7 +97,7 @@ static string[] PlanLines(string stdOut) => /// Writes a scratch env file (the Gh332 GW_ENV_FILE idiom) — never the real .env. static string WriteEnvFile(params string[] assignments) { - var path = Path.Combine(Directory.CreateTempSubdirectory("story343-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, assignments); return path; } diff --git a/tests/GenWave.Host.Tests/Specs/Story344_SetupWizardInterview.cs b/tests/GenWave.Host.Tests/Specs/Story344_SetupWizardInterview.cs index 06a9faf5..e7d2b6fe 100644 --- a/tests/GenWave.Host.Tests/Specs/Story344_SetupWizardInterview.cs +++ b/tests/GenWave.Host.Tests/Specs/Story344_SetupWizardInterview.cs @@ -139,7 +139,7 @@ static string BinWithFailingMv() return dir; } - static string ScratchEnvDir() => Directory.CreateTempSubdirectory("gw-setup-story344-env-").FullName; + static string ScratchEnvDir() => TempDir.CreateForProcessLifetime(); static string ScratchEnvPath() => Path.Combine(ScratchEnvDir(), ".env"); @@ -153,7 +153,7 @@ static string WriteExistingEnvFile(string content) /// A fresh scratch directory holding the given count of .flac/.mp3 files (and nothing else). static string MakeMediaDir(int flacCount = 0, int mp3Count = 0) { - var dir = Directory.CreateTempSubdirectory("gw-setup-story344-media-").FullName; + var dir = TempDir.CreateForProcessLifetime(); for (var i = 0; i < flacCount; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.flac"), ""); for (var i = 0; i < mp3Count; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.mp3"), ""); return dir; @@ -174,7 +174,7 @@ static string MakeMediaDir(int flacCount = 0, int mp3Count = 0) string binDir, string envFile, string stdinAnswers, IReadOnlyDictionary? extraEnv = null) { - var scratchDir = Directory.CreateTempSubdirectory("gw-setup-story344-stdin-").FullName; + var scratchDir = TempDir.CreateForProcessLifetime(); var answersPath = Path.Combine(scratchDir, "answers.txt"); File.WriteAllText(answersPath, stdinAnswers); @@ -190,8 +190,7 @@ static string MakeMediaDir(int flacCount = 0, int mp3Count = 0) /// WriteLaunchStub (file-scoped there too, so duplicated rather than shared). static string WriteExitZeroLaunchStub() { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story344-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); File.WriteAllText(path, "#!/usr/bin/env bash\nexit 0\n"); if (!OperatingSystem.IsWindows()) { @@ -276,7 +275,8 @@ public void TheTopologyRecommendationFollowsDetectedRamAndArch() // GW_MEMINFO_FILE + GW_ARCH report a 3 GiB x86_64 box -> piper-only recommended; the // owner overrides to Full at the prompt (answer "1"). The final .env must honor the // OVERRIDE, not the recommendation. - var meminfo = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story344-ram-").FullName, "meminfo"); + using var meminfoDir = new TempDir(); + var meminfo = Path.Combine(meminfoDir.Path, "meminfo"); File.WriteAllText(meminfo, "MemTotal: 3945000 kB\nMemFree: 100000 kB\n"); var mediaDir = MakeMediaDir(flacCount: 1); var envFile = ScratchEnvPath(); @@ -337,7 +337,8 @@ public void Arm64WithAmpleKnownRamRecommendsFullNotPiperOnly() { // A beefy arm64 box (16 GiB) — bare arch used to force piper-only regardless of // headroom; the recommendation must now follow the RAM, same as any other arch. - var meminfo = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story344-ram-").FullName, "meminfo"); + using var meminfoDir = new TempDir(); + var meminfo = Path.Combine(meminfoDir.Path, "meminfo"); File.WriteAllText(meminfo, "MemTotal: 16000000 kB\nMemFree: 1000000 kB\n"); var mediaDir = MakeMediaDir(flacCount: 1); var envFile = ScratchEnvPath(); diff --git a/tests/GenWave.Host.Tests/Specs/Story345_SetupLaunchClockHandoff.cs b/tests/GenWave.Host.Tests/Specs/Story345_SetupLaunchClockHandoff.cs index de0b5ef2..59ba21ae 100644 --- a/tests/GenWave.Host.Tests/Specs/Story345_SetupLaunchClockHandoff.cs +++ b/tests/GenWave.Host.Tests/Specs/Story345_SetupLaunchClockHandoff.cs @@ -114,6 +114,7 @@ public void Dispose() file sealed class ArmableMountStub : IDisposable { readonly WebApplication app; + readonly TempDir markerDir = new(); public string Url { get; } @@ -121,8 +122,7 @@ public void Dispose() public ArmableMountStub() { - MarkerPath = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-arm-").FullName, "armed"); + MarkerPath = Path.Combine(markerDir.Path, "armed"); var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); builder.WebHost.UseKestrelCore().ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0)); @@ -154,6 +154,7 @@ public void Dispose() { app.StopAsync().GetAwaiter().GetResult(); app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + markerDir.Dispose(); } } @@ -240,12 +241,12 @@ exit 0 static string MakeMediaDir(int flacCount) { - var dir = Directory.CreateTempSubdirectory("gw-setup-story345-media-").FullName; + var dir = TempDir.CreateForProcessLifetime(); for (var i = 0; i < flacCount; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.flac"), ""); return dir; } - static string ScratchEnvDir() => Directory.CreateTempSubdirectory("gw-setup-story345-env-").FullName; + static string ScratchEnvDir() => TempDir.CreateForProcessLifetime(); static string ScratchEnvPath() => Path.Combine(ScratchEnvDir(), ".env"); @@ -269,8 +270,7 @@ static string ReadEnvValue(string envContent, string key) /// under this harness). static string WriteLaunchStub(int exitCode, string? argvLogPath = null) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); var logLine = argvLogPath is null ? "" : $"printf '%s\\n' \"argv:$*\" >> \"{argvLogPath}\"\n" + @@ -290,8 +290,7 @@ static string WriteLaunchStub(int exitCode, string? argvLogPath = null) static string WriteLaunchStubThatArmsMountPartway( string markerPath, int armAfterSeconds, int totalRuntimeSeconds, int exitCode = 0) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); var remainingSeconds = totalRuntimeSeconds - armAfterSeconds; // `: > markerPath`, not `touch` — a shell builtin, so this never depends on `touch` // being on the scratch PATH at all. @@ -307,8 +306,7 @@ static string WriteLaunchStubThatArmsMountPartway( /// (near-instant) return under . static string WriteDelayedLaunchStub(int sleepSeconds, int exitCode) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); File.WriteAllText(path, $"#!/usr/bin/env bash\nsleep {sleepSeconds}\nexit {exitCode}\n"); MakeExecutable(path); return path; @@ -322,8 +320,7 @@ static string WriteDelayedLaunchStub(int sleepSeconds, int exitCode) /// observes launch.sh's own final exit code). static string WriteLaunchStubThatSwallowsInt(int totalRuntimeSeconds, int exitCode) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); File.WriteAllText(path, $"#!/usr/bin/env bash\ntrap '' INT\nsleep {totalRuntimeSeconds}\nexit {exitCode}\n"); MakeExecutable(path); @@ -420,7 +417,7 @@ static Dictionary BaseEnv(string launchCmd, string streamUrl, in static (int ExitCode, string StdOut, string StdErr) RunSetup( string binDir, string envFile, string stdinAnswers, IReadOnlyDictionary extraEnv) { - var scratchDir = Directory.CreateTempSubdirectory("gw-setup-story345-stdin-").FullName; + var scratchDir = TempDir.CreateForProcessLifetime(); var answersPath = Path.Combine(scratchDir, "answers.txt"); File.WriteAllText(answersPath, stdinAnswers); @@ -442,7 +439,7 @@ static Dictionary BaseEnv(string launchCmd, string streamUrl, in string binDir, string envFile, string stdinAnswers, IReadOnlyDictionary extraEnv, TimeSpan sendAfter) { - var workDir = Directory.CreateTempSubdirectory("gw-setup-story345-sigint-").FullName; + var workDir = TempDir.CreateForProcessLifetime(); var pgidFile = Path.Combine(workDir, "pgid"); var answersPath = Path.Combine(workDir, "answers.txt"); File.WriteAllText(answersPath, stdinAnswers); @@ -553,8 +550,8 @@ public void TheWizardInvokesLaunchShWithTheStagedShape() // Bare invocation, no topology flags — GW_PRESET (just written to .env) IS the // topology (F132.5); the wizard's own contract is a bare ./launch.sh (T317 smoke, // the T318 task note's explicit ruling: no --no-launch escape hatch, no flags). - var argvLog = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-argv-").FullName, "argv.log"); + using var argvLogDir = new TempDir(); + var argvLog = Path.Combine(argvLogDir.Path, "argv.log"); var launchStub = WriteLaunchStub(exitCode: 0, argvLogPath: argvLog); // B2 (round-3 review): the stale-mount gate is now universal — a mount already // serving on the FIRST poll is indistinguishable from a stale/pre-existing stack, so @@ -1558,8 +1555,8 @@ public void ThePreflightSummaryAppearsExactlyOnceAndBeforeOnAir() // pinned here is setup.sh's OWN half of the fix: the explicit render-before-print_ // ready_to_launch call, made safe by preflight_print_report's idempotency guard // against the EXIT trap's own unconditional call later. - var argvLog = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story345-argv-").FullName, "argv.log"); + using var argvLogDir = new TempDir(); + var argvLog = Path.Combine(argvLogDir.Path, "argv.log"); var launchStub = WriteLaunchStub(exitCode: 0, argvLogPath: argvLog); using var mount = new MountStub(servesOnAttempt: 2); var mediaDir = MakeMediaDir(flacCount: 1); diff --git a/tests/GenWave.Host.Tests/Specs/Story346_AdoptionVerifyRepair.cs b/tests/GenWave.Host.Tests/Specs/Story346_AdoptionVerifyRepair.cs index ccc94cd9..76296e42 100644 --- a/tests/GenWave.Host.Tests/Specs/Story346_AdoptionVerifyRepair.cs +++ b/tests/GenWave.Host.Tests/Specs/Story346_AdoptionVerifyRepair.cs @@ -59,7 +59,7 @@ public static class FeatureAdoptionVerifyRepair static string MakeScratchCheckout() { var repoRoot = RepoRootLocator.Find(AppContext.BaseDirectory); - var root = Directory.CreateTempSubdirectory("gw-setup-story346-checkout-").FullName; + var root = TempDir.CreateForProcessLifetime(); File.Copy(Path.Combine(repoRoot, "setup.sh"), Path.Combine(root, "setup.sh")); MakeExecutable(Path.Combine(root, "setup.sh")); @@ -107,7 +107,7 @@ static void MakeExecutable(string path) UnixFileMode.OtherRead | UnixFileMode.OtherExecute); } - static string ScratchEnvDir() => Directory.CreateTempSubdirectory("gw-setup-story346-env-").FullName; + static string ScratchEnvDir() => TempDir.CreateForProcessLifetime(); static string ScratchEnvPath() => Path.Combine(ScratchEnvDir(), ".env"); @@ -274,8 +274,7 @@ static string WriteDockerStub( sb.AppendLine(" ;;"); sb.AppendLine("esac"); - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story346-docker-").FullName, "docker-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "docker-stub.sh"); File.WriteAllText(path, sb.ToString()); MakeExecutable(path); return path; @@ -386,7 +385,7 @@ static string WriteRealDbCompose(string repoRoot, string projectName) { var lib = Path.Combine(repoRoot, "db", "01-library.sh"); var station = Path.Combine(repoRoot, "db", "06-station-settings-migration.sh"); - var dir = Directory.CreateTempSubdirectory("gw-setup-story346-realdb-").FullName; + var dir = TempDir.CreateForProcessLifetime(); var compose = $""" name: {projectName} @@ -459,7 +458,7 @@ static void RunDockerCompose(string composePath, string projectName, params stri string scriptPath, string binDir, string envFile, string stdinAnswers, IReadOnlyDictionary extraEnv, string[] args) { - var scratchDir = Directory.CreateTempSubdirectory("gw-setup-story346-stdin-").FullName; + var scratchDir = TempDir.CreateForProcessLifetime(); var answersPath = Path.Combine(scratchDir, "answers.txt"); File.WriteAllText(answersPath, stdinAnswers); @@ -797,7 +796,8 @@ public void VerifyPassesEnvFileToEveryComposeInvocation() var envFile = ScratchEnvPath(); WriteEnvFile(envFile, HealthyEnvValues(Path.GetTempPath(), "compose.yaml")); - var logPath = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story346-log-").FullName, "argv.log"); + using var logPathDir = new TempDir(); + var logPath = Path.Combine(logPathDir.Path, "argv.log"); var docker = WriteDockerStub(logPath: logPath); // every knob at its healthy default var (exitCode, stdOut, _) = RunSetupInCheckout(checkoutRoot, ScriptProcess.MakeBinDir(), envFile, "", @@ -1036,7 +1036,8 @@ public void ADeclinedItemIsSkippedAndTheNextIsOffered() // kokoro's finding is offered first and ollama's second. var envFile = ScratchEnvPath(); WriteEnvFile(envFile, HealthyEnvValues(Path.GetTempPath(), "compose.yaml")); - var logPath = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story346-log-").FullName, "argv.log"); + using var logPathDir = new TempDir(); + var logPath = Path.Combine(logPathDir.Path, "argv.log"); var docker = WriteDockerStub( actualContainers: [ @@ -1061,7 +1062,8 @@ public void DashDashYesAppliesAllFindingsWithoutPrompts() { var envFile = ScratchEnvPath(); WriteEnvFile(envFile, HealthyEnvValues(Path.GetTempPath(), "compose.yaml")); - var logPath = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story346-log-").FullName, "argv.log"); + using var logPathDir = new TempDir(); + var logPath = Path.Combine(logPathDir.Path, "argv.log"); var docker = WriteDockerStub( actualContainers: [ @@ -1131,7 +1133,8 @@ public void EofOnAPerItemConfirmIsTreatedAsDeclinedNotACrash() // never a crash — the run still ends via the ordinary "still outstanding" exit 5. var envFile = ScratchEnvPath(); WriteEnvFile(envFile, HealthyEnvValues(Path.GetTempPath(), "compose.yaml")); - var logPath = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story346-log-").FullName, "argv.log"); + using var logPathDir = new TempDir(); + var logPath = Path.Combine(logPathDir.Path, "argv.log"); var docker = WriteDockerStub( actualContainers: [ @@ -1274,7 +1277,8 @@ public void VerifyModeMakesZeroWritesToTheBox() var checkoutRoot = MakeScratchCheckout(); var envFile = Path.Combine(checkoutRoot, ".env"); WriteEnvFile(envFile, HealthyEnvValues(Path.GetTempPath(), "compose.yaml")); - var logPath = Path.Combine(Directory.CreateTempSubdirectory("gw-setup-story346-log-").FullName, "argv.log"); + using var logPathDir = new TempDir(); + var logPath = Path.Combine(logPathDir.Path, "argv.log"); var docker = WriteDockerStub(logPath: logPath); var before = SnapshotTree(checkoutRoot); diff --git a/tests/GenWave.Host.Tests/Specs/Story386_PluginDoorVisibleAndAdditive.cs b/tests/GenWave.Host.Tests/Specs/Story386_PluginDoorVisibleAndAdditive.cs index b354664f..1fd60ce4 100644 --- a/tests/GenWave.Host.Tests/Specs/Story386_PluginDoorVisibleAndAdditive.cs +++ b/tests/GenWave.Host.Tests/Specs/Story386_PluginDoorVisibleAndAdditive.cs @@ -161,11 +161,12 @@ static async Task GetStatusAsync(HttpClient client) public sealed class ScenarioAPluginProviderJoinsTheFanOut : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; public ScenarioAPluginProviderJoinsTheFanOut() => ExamplePluginBuildOutput.CopyInto(root, "example-dice"); - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task ThePluginContextProviderResolvesAlongsideWeatherAndHistory() @@ -184,9 +185,10 @@ public async Task ThePluginContextProviderResolvesAlongsideWeatherAndHistory() public sealed class ScenarioADriftingKeyGetterNeverReachesContextPipeline : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task TheValidatedKeyReachesThePipelineAndTheHostBootsCleanly() @@ -220,7 +222,8 @@ public async Task TheValidatedKeyReachesThePipelineAndTheHostBootsCleanly() public sealed class ScenarioStatusReportsEveryOutcome : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; public ScenarioStatusReportsEveryOutcome() { @@ -228,7 +231,7 @@ public ScenarioStatusReportsEveryOutcome() WriteBrokenPlugin(root, "broken-plugin"); } - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task PluginsArrayCarriesTheLoadedPluginRow() @@ -275,9 +278,10 @@ public async Task BootWritesOneBoothLogNarrativeRowPerPluginOutcome() public sealed class ScenarioASlugEmbeddingCrLfIsNeutralized : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task ACrLfEmbeddedSlugProducesASingleLineBoothSummaryAndLogMessage() @@ -322,11 +326,12 @@ public async Task ACrLfEmbeddedSlugProducesASingleLineBoothSummaryAndLogMessage( public sealed class ScenarioPluginSettingsReadTheirOwnSection : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; public ScenarioPluginSettingsReadTheirOwnSection() => ExamplePluginBuildOutput.CopyInto(root, "example-dice"); - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task SettingReturnsTheConfiguredValueFromPluginsName() @@ -388,39 +393,33 @@ public sealed class ScenarioTheClosedDoorIsInert [Fact] public async Task OneKnobAloneLoadsNothingAndSaysWhichHalfIsMissing() { - var root = Directory.CreateTempSubdirectory("genwave-host-plugin-door-").FullName; - try - { - ExamplePluginBuildOutput.CopyInto(root, "example-dice"); - - // A path guaranteed never to exist — never the literal Plugins:Root DEFAULT, so this - // fact never depends on whatever this machine's own filesystem happens to hold at - // "/plugins" (STORY-385 AC1's own "root missing = Directory.Exists false" mechanism, - // proven against a path this test fully controls). - var neverMounted = Path.Combine(Path.GetTempPath(), $"genwave-plugins-never-mounted-{Guid.NewGuid():n}"); + using var rootDir = new TempDir(); + var root = rootDir.Path; + ExamplePluginBuildOutput.CopyInto(root, "example-dice"); - await using (var enabledOnly = new PluginDoorWebFactory(neverMounted, enabled: true)) - { - var status = enabledOnly.Services.GetRequiredService(); - Assert.NotNull(status.MissingKnobNote); - Assert.Contains("mounted", status.MissingKnobNote, StringComparison.Ordinal); - Assert.DoesNotContain( - enabledOnly.Services.GetServices(), p => p.Key == "example-dice"); - } + // A path guaranteed never to exist — never the literal Plugins:Root DEFAULT, so this + // fact never depends on whatever this machine's own filesystem happens to hold at + // "/plugins" (STORY-385 AC1's own "root missing = Directory.Exists false" mechanism, + // proven against a path this test fully controls). + var neverMounted = Path.Combine(Path.GetTempPath(), $"genwave-plugins-never-mounted-{Guid.NewGuid():n}"); - // The inverse: a real mount, but Plugins:Enabled left unset. - await using (var mountOnly = new PluginDoorWebFactory(root, enabled: null)) - { - var status = mountOnly.Services.GetRequiredService(); - Assert.NotNull(status.MissingKnobNote); - Assert.Contains("Enabled", status.MissingKnobNote, StringComparison.Ordinal); - Assert.DoesNotContain( - mountOnly.Services.GetServices(), p => p.Key == "example-dice"); - } + await using (var enabledOnly = new PluginDoorWebFactory(neverMounted, enabled: true)) + { + var status = enabledOnly.Services.GetRequiredService(); + Assert.NotNull(status.MissingKnobNote); + Assert.Contains("mounted", status.MissingKnobNote, StringComparison.Ordinal); + Assert.DoesNotContain( + enabledOnly.Services.GetServices(), p => p.Key == "example-dice"); } - finally + + // The inverse: a real mount, but Plugins:Enabled left unset. + await using (var mountOnly = new PluginDoorWebFactory(root, enabled: null)) { - Directory.Delete(root, recursive: true); + var status = mountOnly.Services.GetRequiredService(); + Assert.NotNull(status.MissingKnobNote); + Assert.Contains("Enabled", status.MissingKnobNote, StringComparison.Ordinal); + Assert.DoesNotContain( + mountOnly.Services.GetServices(), p => p.Key == "example-dice"); } } diff --git a/tests/GenWave.Host.Tests/Specs/Story388_AdSpotSourceRegistrationOrder.cs b/tests/GenWave.Host.Tests/Specs/Story388_AdSpotSourceRegistrationOrder.cs index f5e79522..c4004e5c 100644 --- a/tests/GenWave.Host.Tests/Specs/Story388_AdSpotSourceRegistrationOrder.cs +++ b/tests/GenWave.Host.Tests/Specs/Story388_AdSpotSourceRegistrationOrder.cs @@ -48,13 +48,14 @@ sealed class Source : IAdSpotSource public sealed class ScenarioThePluginSourceComesFirst : IDisposable { - readonly string root = Directory.CreateTempSubdirectory("genwave-host-ad-spot-order-").FullName; + readonly TempDir rootDir = new(); + string root => rootDir.Path; public ScenarioThePluginSourceComesFirst() => EmittedHostTestPlugin.CreateInto( root, "ad-order-plugin", "Ad Order Plugin", "AdOrderPlugin.EntryPoint", PluginSource); - public void Dispose() => Directory.Delete(root, recursive: true); + public void Dispose() => rootDir.Dispose(); [Fact] public async Task ThePluginSourceResolvesBeforeTheLibraryFloor() diff --git a/tests/GenWave.Host.Tests/Specs/Story395_VoicePackInstall.cs b/tests/GenWave.Host.Tests/Specs/Story395_VoicePackInstall.cs index 7c116c6f..e1f576cf 100644 --- a/tests/GenWave.Host.Tests/Specs/Story395_VoicePackInstall.cs +++ b/tests/GenWave.Host.Tests/Specs/Story395_VoicePackInstall.cs @@ -28,6 +28,7 @@ using GenWave.Host.Api; using GenWave.Host.Catalog; using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; namespace GenWave.Host.Tests.Specs; @@ -560,59 +561,51 @@ public async Task ACancelledUpsertDuringAReinstallLeavesThePreviousInstallUntouc // way. A SECOND, fresh factory instance is also what a real cancelled-reinstall needs — // CatalogProxyService's own 15-minute cache means only a cold cache (a fresh app instance) // actually re-fetches instead of replaying the FIRST install's own cached asset bytes. - var voicesRoot = Directory.CreateTempSubdirectory("t413-story395-voices-reinstall-").FullName; - try + using var voicesRootDir = new TempDir(); + var voicesRoot = voicesRootDir.Path; + var store = new FakeVoicePackStore(); + string firstHash, secondHash; + await using (var firstFactory = new VoicePackInstallWebFactory( + store, voicesRoot, VoicePackInstallFixtures.FirstPtBytes, VoicePackInstallFixtures.SecondPtBytes)) { - var store = new FakeVoicePackStore(); - string firstHash, secondHash; - await using (var firstFactory = new VoicePackInstallWebFactory( - store, voicesRoot, VoicePackInstallFixtures.FirstPtBytes, VoicePackInstallFixtures.SecondPtBytes)) - { - var firstClient = await VoicePackInstallWebFactory.LoggedInClientAsync(firstFactory); - var first = await firstClient.PostAsync($"/api/voice-packs/{VoicePackInstallFixtures.InstallSlug}/install", null); - Assert.True(first.IsSuccessStatusCode, await first.Content.ReadAsStringAsync()); - - firstHash = Convert.ToHexStringLower(SHA256.HashData( - await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_first.pt")))); - secondHash = Convert.ToHexStringLower(SHA256.HashData( - await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_second.pt")))); - } - - store.ThrowOnUpsert = new OperationCanceledException("simulated client disconnect mid-upsert"); - await using (var secondFactory = new VoicePackInstallWebFactory( - store, voicesRoot, - VoicePackInstallFixtures.SecondInstallFirstPtBytes, VoicePackInstallFixtures.SecondInstallSecondPtBytes)) - { - var secondClient = await VoicePackInstallWebFactory.LoggedInClientAsync(secondFactory); - try - { - await secondClient.PostAsync($"/api/voice-packs/{VoicePackInstallFixtures.InstallSlug}/install", null); - } - catch (OperationCanceledException) - { - // Rethrowing past the action (rather than answering with a 500) is the fix's - // own point — the client genuinely disconnected, so there is no one left to - // answer. TestServer resurfaces that unhandled exception at the call site; - // either way, the disk/DB state asserted below is what this fact pins. - } - } + var firstClient = await VoicePackInstallWebFactory.LoggedInClientAsync(firstFactory); + var first = await firstClient.PostAsync($"/api/voice-packs/{VoicePackInstallFixtures.InstallSlug}/install", null); + Assert.True(first.IsSuccessStatusCode, await first.Content.ReadAsStringAsync()); - var restoredFirstHash = Convert.ToHexStringLower(SHA256.HashData( + firstHash = Convert.ToHexStringLower(SHA256.HashData( await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_first.pt")))); - var restoredSecondHash = Convert.ToHexStringLower(SHA256.HashData( + secondHash = Convert.ToHexStringLower(SHA256.HashData( await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_second.pt")))); - Assert.Equal(firstHash, restoredFirstHash); - Assert.Equal(secondHash, restoredSecondHash); - Assert.Equal(1, store.PackCount); - Assert.Empty(Directory.EnumerateFiles(voicesRoot, "*.tmp")); - Assert.Empty(Directory.EnumerateFiles(voicesRoot, "*.prev-*")); } - finally + + store.ThrowOnUpsert = new OperationCanceledException("simulated client disconnect mid-upsert"); + await using (var secondFactory = new VoicePackInstallWebFactory( + store, voicesRoot, + VoicePackInstallFixtures.SecondInstallFirstPtBytes, VoicePackInstallFixtures.SecondInstallSecondPtBytes)) { - try { Directory.Delete(voicesRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + var secondClient = await VoicePackInstallWebFactory.LoggedInClientAsync(secondFactory); + try + { + await secondClient.PostAsync($"/api/voice-packs/{VoicePackInstallFixtures.InstallSlug}/install", null); + } + catch (OperationCanceledException) + { + // Rethrowing past the action (rather than answering with a 500) is the fix's + // own point — the client genuinely disconnected, so there is no one left to + // answer. TestServer resurfaces that unhandled exception at the call site; + // either way, the disk/DB state asserted below is what this fact pins. + } } + + var restoredFirstHash = Convert.ToHexStringLower(SHA256.HashData( + await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_first.pt")))); + var restoredSecondHash = Convert.ToHexStringLower(SHA256.HashData( + await File.ReadAllBytesAsync(Path.Combine(voicesRoot, "af_second.pt")))); + Assert.Equal(firstHash, restoredFirstHash); + Assert.Equal(secondHash, restoredSecondHash); + Assert.Equal(1, store.PackCount); + Assert.Empty(Directory.EnumerateFiles(voicesRoot, "*.tmp")); + Assert.Empty(Directory.EnumerateFiles(voicesRoot, "*.prev-*")); } } @@ -1098,7 +1091,7 @@ public async Task ThePreviewMp3ServesWithAudioMpegContentType() readonly FakeVoicePackStore store; readonly FakeHttpMessageHandler handler; - readonly bool ownsVoicesRoot; + readonly TempDir? ownedVoicesRoot; readonly bool adminEnabled; public string VoicesRoot { get; } @@ -1107,8 +1100,7 @@ public async Task ThePreviewMp3ServesWithAudioMpegContentType() /// listing scenario reuse this factory for its kill-switch fact rather than standing up a /// parallel one — every other caller leaves it at its default (true, the app's own default). public VoicePackInstallWebFactory(FakeVoicePackStore store, bool adminEnabled = true) - : this(store, Directory.CreateTempSubdirectory("t413-story395-voices-").FullName, - VoicePackInstallFixtures.FirstPtBytes, VoicePackInstallFixtures.SecondPtBytes, ownsVoicesRoot: true, adminEnabled) + : this(store, new TempDir(), VoicePackInstallFixtures.FirstPtBytes, VoicePackInstallFixtures.SecondPtBytes, adminEnabled) { } @@ -1123,17 +1115,26 @@ public VoicePackInstallWebFactory(FakeVoicePackStore store, bool adminEnabled = /// both instances. /// public VoicePackInstallWebFactory(FakeVoicePackStore store, string voicesRoot, byte[] firstPtBytes, byte[] secondPtBytes) - : this(store, voicesRoot, firstPtBytes, secondPtBytes, ownsVoicesRoot: false, adminEnabled: true) + : this(store, voicesRoot, firstPtBytes, secondPtBytes, ownedVoicesRoot: null, adminEnabled: true) + { + } + + /// The self-creating constructor's own flows through as BOTH the + /// resolved path and the disposer this instance owns — created once, + /// here, never twice. + VoicePackInstallWebFactory( + FakeVoicePackStore store, TempDir ownedRoot, byte[] firstPtBytes, byte[] secondPtBytes, bool adminEnabled) + : this(store, ownedRoot.Path, firstPtBytes, secondPtBytes, ownedRoot, adminEnabled) { } VoicePackInstallWebFactory( - FakeVoicePackStore store, string voicesRoot, byte[] firstPtBytes, byte[] secondPtBytes, bool ownsVoicesRoot, + FakeVoicePackStore store, string voicesRoot, byte[] firstPtBytes, byte[] secondPtBytes, TempDir? ownedVoicesRoot, bool adminEnabled) { this.store = store; VoicesRoot = voicesRoot; - this.ownsVoicesRoot = ownsVoicesRoot; + this.ownedVoicesRoot = ownedVoicesRoot; this.adminEnabled = adminEnabled; handler = VoicePackInstallFixtures.BuildRoutedHandler(voicesRoot, firstPtBytes, secondPtBytes); } @@ -1160,11 +1161,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void Dispose(bool disposing) { - if (disposing && ownsVoicesRoot) + if (disposing) { - try { Directory.Delete(VoicesRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + ownedVoicesRoot?.Dispose(); } base.Dispose(disposing); diff --git a/tests/GenWave.Host.Tests/Specs/Story396_VoicePackWrongEngineRefused.cs b/tests/GenWave.Host.Tests/Specs/Story396_VoicePackWrongEngineRefused.cs index abc3afd7..2e3c80ac 100644 --- a/tests/GenWave.Host.Tests/Specs/Story396_VoicePackWrongEngineRefused.cs +++ b/tests/GenWave.Host.Tests/Specs/Story396_VoicePackWrongEngineRefused.cs @@ -25,6 +25,7 @@ using GenWave.Core.Abstractions; using GenWave.Host.Api; using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; namespace GenWave.Host.Tests.Specs; @@ -328,8 +329,9 @@ public async Task APiperPrimaryStationStillRefusesAPiperDeclaredPack() readonly FakeVoicePackStore store; readonly FakeHttpMessageHandler handler; readonly string? piperPrimaryEndpoint; + readonly TempDir voicesRootDir = new(); - public string VoicesRoot { get; } = Directory.CreateTempSubdirectory("t413-story396-voices-").FullName; + public string VoicesRoot => voicesRootDir.Path; public VoicePackEngineWebFactory(FakeVoicePackStore store, string? piperPrimaryEndpoint = null) { @@ -363,9 +365,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - try { Directory.Delete(VoicesRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + voicesRootDir.Dispose(); } base.Dispose(disposing); diff --git a/tests/GenWave.Host.Tests/Specs/Story398_VoiceIdCollisionRefused.cs b/tests/GenWave.Host.Tests/Specs/Story398_VoiceIdCollisionRefused.cs index b19380ed..8ebbac2c 100644 --- a/tests/GenWave.Host.Tests/Specs/Story398_VoiceIdCollisionRefused.cs +++ b/tests/GenWave.Host.Tests/Specs/Story398_VoiceIdCollisionRefused.cs @@ -22,6 +22,7 @@ using Microsoft.Extensions.Hosting; using GenWave.Core.Abstractions; using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; namespace GenWave.Host.Tests.Specs; @@ -192,8 +193,9 @@ public async Task BothVoiceIdsAppearInGetApiVoicesAfterASuccessfulRename() readonly FakeVoicePackStore store; readonly FakeHttpMessageHandler handler; + readonly TempDir voicesRootDir = new(); - public string VoicesRoot { get; } = Directory.CreateTempSubdirectory("t413-story398-voices-").FullName; + public string VoicesRoot => voicesRootDir.Path; public VoicePackCollisionWebFactory(FakeVoicePackStore store) { @@ -224,9 +226,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - try { Directory.Delete(VoicesRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + voicesRootDir.Dispose(); } base.Dispose(disposing); diff --git a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackEnrichmentAndHashFailures.cs b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackEnrichmentAndHashFailures.cs index d57b18f2..024486f5 100644 --- a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackEnrichmentAndHashFailures.cs +++ b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackEnrichmentAndHashFailures.cs @@ -39,6 +39,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; using GenWave.Host.Tests.Fakes; +using GenWave.Host.Tests.Support; namespace GenWave.Host.Tests.Specs; @@ -319,7 +320,8 @@ public async Task TheUnwindRunsBeforeTheCancellationPropagates() // against the SAME URLs. const string slug = "cancelled-reinstall-pack"; const string keptFile = "kept.wav"; - var jingleRoot = Directory.CreateTempSubdirectory("t414-r2-f69-").FullName; + using var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -369,9 +371,6 @@ public async Task TheUnwindRunsBeforeTheCancellationPropagates() try { Directory.Delete(assetDir, recursive: true); } catch (IOException) { /* best-effort cleanup */ } catch (UnauthorizedAccessException) { /* best-effort cleanup */ } - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } } } } @@ -392,14 +391,15 @@ public async Task TheUnwindRunsBeforeTheCancellationPropagates() public HttpStatusCode Status { get; private init; } public string Body { get; private init; } = ""; - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } + readonly TempDir jingleRootDir; readonly EnrichmentFailureWebFactory factory; - FreshCorruptArc(string jingleRoot, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) + FreshCorruptArc(TempDir jingleRootDir, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.factory = factory; Status = status; @@ -408,7 +408,8 @@ public async Task TheUnwindRunsBeforeTheCancellationPropagates() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-r2-f1-fresh-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -432,16 +433,14 @@ public static async Task RunAsync() var response = await client.PostAsync($"/api/jingle-packs/{Slug}/install", null); var body = await response.Content.ReadAsStringAsync(); - return new FreshCorruptArc(jingleRoot, store, factory, response.StatusCode, body); + return new FreshCorruptArc(jingleRootDir, store, factory, response.StatusCode, body); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } finally @@ -455,9 +454,7 @@ public static async Task RunAsync() public void Dispose() { factory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } @@ -476,14 +473,15 @@ public void Dispose() public HttpStatusCode Status { get; private init; } public string Body { get; private init; } = ""; - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } + readonly TempDir jingleRootDir; readonly EnrichmentFailureWebFactory factory; - TagLibUnreadableArc(string jingleRoot, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) + TagLibUnreadableArc(TempDir jingleRootDir, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.factory = factory; Status = status; @@ -492,7 +490,8 @@ public void Dispose() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-delta-taglib-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -511,16 +510,14 @@ public static async Task RunAsync() var response = await client.PostAsync($"/api/jingle-packs/{Slug}/install", null); var body = await response.Content.ReadAsStringAsync(); - return new TagLibUnreadableArc(jingleRoot, store, factory, response.StatusCode, body); + return new TagLibUnreadableArc(jingleRootDir, store, factory, response.StatusCode, body); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } finally @@ -534,9 +531,7 @@ public static async Task RunAsync() public void Dispose() { factory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } @@ -555,14 +550,15 @@ public void Dispose() public HttpStatusCode Status { get; private init; } public string Body { get; private init; } = ""; - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } + readonly TempDir jingleRootDir; readonly EnrichmentFailureWebFactory factory; - ZeroDurationArc(string jingleRoot, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) + ZeroDurationArc(TempDir jingleRootDir, FakeJinglePackStore store, EnrichmentFailureWebFactory factory, HttpStatusCode status, string body) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.factory = factory; Status = status; @@ -571,7 +567,8 @@ public void Dispose() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-delta-zerodur-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -590,16 +587,14 @@ public static async Task RunAsync() var response = await client.PostAsync($"/api/jingle-packs/{Slug}/install", null); var body = await response.Content.ReadAsStringAsync(); - return new ZeroDurationArc(jingleRoot, store, factory, response.StatusCode, body); + return new ZeroDurationArc(jingleRootDir, store, factory, response.StatusCode, body); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } finally @@ -613,9 +608,7 @@ public static async Task RunAsync() public void Dispose() { factory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } @@ -628,19 +621,20 @@ public void Dispose() public const string OtherFile = "other.wav"; public HttpStatusCode SecondStatus { get; private init; } - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } public string FirstKeptBytesHash { get; private init; } = ""; public string FirstOtherBytesHash { get; private init; } = ""; + readonly TempDir jingleRootDir; readonly EnrichmentFailureWebFactory firstFactory; readonly EnrichmentFailureWebFactory secondFactory; ReinstallCorruptArc( - string jingleRoot, FakeJinglePackStore store, EnrichmentFailureWebFactory firstFactory, EnrichmentFailureWebFactory secondFactory, + TempDir jingleRootDir, FakeJinglePackStore store, EnrichmentFailureWebFactory firstFactory, EnrichmentFailureWebFactory secondFactory, HttpStatusCode secondStatus, string firstKeptHash, string firstOtherHash) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.firstFactory = firstFactory; this.secondFactory = secondFactory; @@ -651,7 +645,8 @@ public void Dispose() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-r2-f1-reinstall-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -690,16 +685,14 @@ public static async Task RunAsync() var secondInstall = await secondClient.PostAsync($"/api/jingle-packs/{Slug}/install", null); return new ReinstallCorruptArc( - jingleRoot, store, firstFactory, secondFactory, secondInstall.StatusCode, firstKeptHash, firstOtherHash); + jingleRootDir, store, firstFactory, secondFactory, secondInstall.StatusCode, firstKeptHash, firstOtherHash); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } finally @@ -714,9 +707,7 @@ public void Dispose() { firstFactory.Dispose(); secondFactory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } @@ -728,14 +719,15 @@ public void Dispose() const string File1 = "asset.wav"; public HttpStatusCode Status { get; private init; } - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } + readonly TempDir jingleRootDir; readonly HashMismatchWebFactory factory; - HashMismatchArc(string jingleRoot, FakeJinglePackStore store, HashMismatchWebFactory factory, HttpStatusCode status) + HashMismatchArc(TempDir jingleRootDir, FakeJinglePackStore store, HashMismatchWebFactory factory, HttpStatusCode status) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.factory = factory; Status = status; @@ -743,7 +735,8 @@ public void Dispose() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-r2-f61-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; var assetDir = JingleTestAudio.NewTempDir(); try { @@ -755,16 +748,14 @@ public static async Task RunAsync() var client = await HashMismatchWebFactory.LoggedInClientAsync(factory); var response = await client.PostAsync($"/api/jingle-packs/{Slug}/install", null); - return new HashMismatchArc(jingleRoot, store, factory, response.StatusCode); + return new HashMismatchArc(jingleRootDir, store, factory, response.StatusCode); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } finally @@ -778,9 +769,7 @@ public static async Task RunAsync() public void Dispose() { factory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } @@ -792,14 +781,15 @@ public void Dispose() public HttpStatusCode Status { get; private init; } public string Body { get; private init; } = ""; - public string JingleRoot { get; } + public string JingleRoot => jingleRootDir.Path; public FakeJinglePackStore Store { get; } + readonly TempDir jingleRootDir; readonly PathEscapeWebFactory factory; - PathEscapeArc(string jingleRoot, FakeJinglePackStore store, PathEscapeWebFactory factory, HttpStatusCode status, string body) + PathEscapeArc(TempDir jingleRootDir, FakeJinglePackStore store, PathEscapeWebFactory factory, HttpStatusCode status, string body) { - JingleRoot = jingleRoot; + this.jingleRootDir = jingleRootDir; Store = store; this.factory = factory; Status = status; @@ -808,7 +798,8 @@ public void Dispose() public static async Task RunAsync() { - var jingleRoot = Directory.CreateTempSubdirectory("t414-r3-pathescape-").FullName; + var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; try { var store = new FakeJinglePackStore(); @@ -818,16 +809,14 @@ public static async Task RunAsync() var response = await client.PostAsync($"/api/jingle-packs/{Slug}/install", null); var body = await response.Content.ReadAsStringAsync(); - return new PathEscapeArc(jingleRoot, store, factory, response.StatusCode, body); + return new PathEscapeArc(jingleRootDir, store, factory, response.StatusCode, body); } catch { // Construction failed before ownership of jingleRoot passed to the returned instance's own // Dispose (the `using` in the calling Scenario never runs when RunAsync itself throws) — clean // it up here or it leaks forever (T414 review round 3 finding 5). - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); throw; } } @@ -835,9 +824,7 @@ public static async Task RunAsync() public void Dispose() { factory.Dispose(); - try { Directory.Delete(JingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + jingleRootDir.Dispose(); } } diff --git a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackInstall.cs b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackInstall.cs index b7f3802e..afa5cea9 100644 --- a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackInstall.cs +++ b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackInstall.cs @@ -272,56 +272,48 @@ public sealed class JinglePackInstallArc : IAsyncLifetime public async Task InitializeAsync() { await using var database = await JinglePackInstallDatabase.StartAsync(); - var jingleRoot = Directory.CreateTempSubdirectory("t414-story399-jingle-").FullName; - try - { - AdsLibraryId = await SeedAdsLibraryAsync(database.LibraryConnectionString); + using var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; + AdsLibraryId = await SeedAdsLibraryAsync(database.LibraryConnectionString); - await using var factory = new JinglePackInstallWebFactory(database, jingleRoot); - var client = await JinglePackInstallWebFactory.LoggedInClientAsync(factory); + await using var factory = new JinglePackInstallWebFactory(database, jingleRoot); + var client = await JinglePackInstallWebFactory.LoggedInClientAsync(factory); - var install = await client.PostAsync($"/api/jingle-packs/{JinglePackInstallFixtures.Slug}/install", null); - if (!install.IsSuccessStatusCode) - throw new InvalidOperationException( - $"fixture install of '{JinglePackInstallFixtures.Slug}' failed: {await install.Content.ReadAsStringAsync()}"); + var install = await client.PostAsync($"/api/jingle-packs/{JinglePackInstallFixtures.Slug}/install", null); + if (!install.IsSuccessStatusCode) + throw new InvalidOperationException( + $"fixture install of '{JinglePackInstallFixtures.Slug}' failed: {await install.Content.ReadAsStringAsync()}"); - InstalledAssetPathsExist = JinglePackInstallFixtures.AssetFiles.All( - file => File.Exists(Path.Combine(jingleRoot, JinglePackInstallFixtures.Slug, file))); + InstalledAssetPathsExist = JinglePackInstallFixtures.AssetFiles.All( + file => File.Exists(Path.Combine(jingleRoot, JinglePackInstallFixtures.Slug, file))); - InstalledAssetByteLengthsMatch = JinglePackInstallFixtures.AssetFiles.All(file => - { - var onDiskPath = Path.Combine(jingleRoot, JinglePackInstallFixtures.Slug, file); - return File.Exists(onDiskPath) - && File.ReadAllBytes(onDiskPath).Length == JinglePackInstallFixtures.AssetBytes(file).Length; - }); + InstalledAssetByteLengthsMatch = JinglePackInstallFixtures.AssetFiles.All(file => + { + var onDiskPath = Path.Combine(jingleRoot, JinglePackInstallFixtures.Slug, file); + return File.Exists(onDiskPath) + && File.ReadAllBytes(onDiskPath).Length == JinglePackInstallFixtures.AssetBytes(file).Length; + }); - NoStagingSiblingSurvives = !Directory.EnumerateDirectories(jingleRoot, "*.staging-*").Any(); + NoStagingSiblingSurvives = !Directory.EnumerateDirectories(jingleRoot, "*.staging-*").Any(); - InstalledRows = await ReadInstalledRowsAsync(database.LibraryConnectionString); + InstalledRows = await ReadInstalledRowsAsync(database.LibraryConnectionString); - var random = await client.GetAsync("/media/random"); - RandomAfterInstallStatus = random.StatusCode; + var random = await client.GetAsync("/media/random"); + RandomAfterInstallStatus = random.StatusCode; - var listing = await client.GetAsync("/api/jingle-packs"); - ListingAfterInstall = await listing.Content.ReadFromJsonAsync() ?? []; + var listing = await client.GetAsync("/api/jingle-packs"); + ListingAfterInstall = await listing.Content.ReadFromJsonAsync() ?? []; - await SeedMalformedDefinitionAsync(database.StationConnectionString); - var malformedListing = await client.GetAsync("/api/jingle-packs"); - MalformedListingStatus = malformedListing.StatusCode; - var afterMalformedInsert = - await malformedListing.Content.ReadFromJsonAsync() ?? []; - MalformedListingSummary = afterMalformedInsert - .SingleOrDefault(summary => summary.Slug == JinglePackInstallFixtures.MalformedSlug); + await SeedMalformedDefinitionAsync(database.StationConnectionString); + var malformedListing = await client.GetAsync("/api/jingle-packs"); + MalformedListingStatus = malformedListing.StatusCode; + var afterMalformedInsert = + await malformedListing.Content.ReadFromJsonAsync() ?? []; + MalformedListingSummary = afterMalformedInsert + .SingleOrDefault(summary => summary.Slug == JinglePackInstallFixtures.MalformedSlug); - BedPoolIds = await factory.Services.GetRequiredService() - .ListReadyBedIdsAsync(AdsLibraryId, CancellationToken.None); - } - finally - { - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } - } + BedPoolIds = await factory.Services.GetRequiredService() + .ListReadyBedIdsAsync(AdsLibraryId, CancellationToken.None); } static async Task SeedAdsLibraryAsync(string libraryConnectionString) @@ -741,27 +733,19 @@ public sealed class JinglePackToneInstallArc : IAsyncLifetime public async Task InitializeAsync() { await using var database = await JinglePackToneInstallDatabase.StartAsync(); - var jingleRoot = Directory.CreateTempSubdirectory("t414-story399-jingle-tone-").FullName; - try - { - await SeedAdsLibraryAsync(database.LibraryConnectionString); + using var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; + await SeedAdsLibraryAsync(database.LibraryConnectionString); - await using var factory = new JinglePackToneInstallWebFactory(database, jingleRoot); - var client = await JinglePackToneInstallWebFactory.LoggedInClientAsync(factory); + await using var factory = new JinglePackToneInstallWebFactory(database, jingleRoot); + var client = await JinglePackToneInstallWebFactory.LoggedInClientAsync(factory); - var install = await client.PostAsync($"/api/jingle-packs/{JinglePackToneInstallFixtures.Slug}/install", null); - InstallStatusCode = install.StatusCode; - InstallBody = await install.Content.ReadAsStringAsync(); + var install = await client.PostAsync($"/api/jingle-packs/{JinglePackToneInstallFixtures.Slug}/install", null); + InstallStatusCode = install.StatusCode; + InstallBody = await install.Content.ReadAsStringAsync(); - if (InstallStatusCode == HttpStatusCode.OK) - InstalledRows = await ReadInstalledRowsAsync(database.LibraryConnectionString); - } - finally - { - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } - } + if (InstallStatusCode == HttpStatusCode.OK) + InstalledRows = await ReadInstalledRowsAsync(database.LibraryConnectionString); } static async Task SeedAdsLibraryAsync(string libraryConnectionString) diff --git a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackReinstallOrphans.cs b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackReinstallOrphans.cs index 8ced8cb8..202eec68 100644 --- a/tests/GenWave.Host.Tests/Specs/Story399_JinglePackReinstallOrphans.cs +++ b/tests/GenWave.Host.Tests/Specs/Story399_JinglePackReinstallOrphans.cs @@ -125,20 +125,12 @@ public sealed class JinglePackReinstallOrphansArc : IAsyncLifetime public async Task InitializeAsync() { await using var database = await JinglePackReinstallOrphansDatabase.StartAsync(); - var jingleRoot = Directory.CreateTempSubdirectory("t414-r2-f34-jingle-").FullName; - try - { - await SeedAdsLibraryAsync(database.LibraryConnectionString); + using var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; + await SeedAdsLibraryAsync(database.LibraryConnectionString); - await RunCleanDropAsync(database, jingleRoot); - await RunRefusedDropAsync(database, jingleRoot); - } - finally - { - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } - } + await RunCleanDropAsync(database, jingleRoot); + await RunRefusedDropAsync(database, jingleRoot); } async Task RunCleanDropAsync(JinglePackReinstallOrphansDatabase database, string jingleRoot) diff --git a/tests/GenWave.Host.Tests/Specs/Story401_PackUninstallGuards.cs b/tests/GenWave.Host.Tests/Specs/Story401_PackUninstallGuards.cs index 1a8ecf55..dd73e5cf 100644 --- a/tests/GenWave.Host.Tests/Specs/Story401_PackUninstallGuards.cs +++ b/tests/GenWave.Host.Tests/Specs/Story401_PackUninstallGuards.cs @@ -227,65 +227,57 @@ public sealed class VoicePackUninstallArc : IAsyncLifetime public async Task InitializeAsync() { await using var database = await VoicePackUninstallDatabase.StartAsync(); - var voicesRoot = Directory.CreateTempSubdirectory("t413-story401-voices-").FullName; - try - { - await using var factory = new VoicePackUninstallWebFactory(database, voicesRoot); - var client = await VoicePackUninstallWebFactory.LoggedInClientAsync(factory); + using var voicesRootDir = new TempDir(); + var voicesRoot = voicesRootDir.Path; + await using var factory = new VoicePackUninstallWebFactory(database, voicesRoot); + var client = await VoicePackUninstallWebFactory.LoggedInClientAsync(factory); - foreach (var slug in VoicePackUninstallFixtures.AllSlugs) - { - var install = await client.PostAsync($"/api/voice-packs/{slug}/install", null); - if (!install.IsSuccessStatusCode) - throw new InvalidOperationException( - $"fixture install of '{slug}' failed: {await install.Content.ReadAsStringAsync()}"); - } + foreach (var slug in VoicePackUninstallFixtures.AllSlugs) + { + var install = await client.PostAsync($"/api/voice-packs/{slug}/install", null); + if (!install.IsSuccessStatusCode) + throw new InvalidOperationException( + $"fixture install of '{slug}' failed: {await install.Content.ReadAsStringAsync()}"); + } - // ── AC1 — an approved spot's own voice_plan blocks the guard pack ── - SpotGuardAdSpotId = await InsertAdSpotAsync( - database.StationConnectionString, state: "approved", voiceId: "af_spotguard", failReason: null); + // ── AC1 — an approved spot's own voice_plan blocks the guard pack ── + SpotGuardAdSpotId = await InsertAdSpotAsync( + database.StationConnectionString, state: "approved", voiceId: "af_spotguard", failReason: null); - var spotGuardResponse = await client.DeleteAsync("/api/voice-packs/spot-guard-pack"); - SpotGuardDeleteStatus = spotGuardResponse.StatusCode; - SpotGuardDeleteBody = await spotGuardResponse.Content.ReadAsStringAsync(); - SpotGuardPackRowSurvivesRefusal = await PackRowExistsAsync(database.StationConnectionString, "spot-guard-pack"); + var spotGuardResponse = await client.DeleteAsync("/api/voice-packs/spot-guard-pack"); + SpotGuardDeleteStatus = spotGuardResponse.StatusCode; + SpotGuardDeleteBody = await spotGuardResponse.Content.ReadAsStringAsync(); + SpotGuardPackRowSurvivesRefusal = await PackRowExistsAsync(database.StationConnectionString, "spot-guard-pack"); - // ── AC2 — a persona's own voice blocks the guard pack ── - await InsertPersonaAsync(database.StationConnectionString, PersonaGuardPersonaName, "af_personaguard"); + // ── AC2 — a persona's own voice blocks the guard pack ── + await InsertPersonaAsync(database.StationConnectionString, PersonaGuardPersonaName, "af_personaguard"); - var personaGuardResponse = await client.DeleteAsync("/api/voice-packs/persona-guard-pack"); - PersonaGuardDeleteStatus = personaGuardResponse.StatusCode; - PersonaGuardDeleteBody = await personaGuardResponse.Content.ReadAsStringAsync(); + var personaGuardResponse = await client.DeleteAsync("/api/voice-packs/persona-guard-pack"); + PersonaGuardDeleteStatus = personaGuardResponse.StatusCode; + PersonaGuardDeleteBody = await personaGuardResponse.Content.ReadAsStringAsync(); - // ── AC6 — the guard runs INSIDE the delete, never against an earlier, now-stale check ── - RaceAdvisoryCheckCountBeforeInsert = - await CountReferencingAdSpotsAsync(database.StationConnectionString, "af_raceguard"); - await InsertAdSpotAsync(database.StationConnectionString, state: "approved", voiceId: "af_raceguard", failReason: null); + // ── AC6 — the guard runs INSIDE the delete, never against an earlier, now-stale check ── + RaceAdvisoryCheckCountBeforeInsert = + await CountReferencingAdSpotsAsync(database.StationConnectionString, "af_raceguard"); + await InsertAdSpotAsync(database.StationConnectionString, state: "approved", voiceId: "af_raceguard", failReason: null); - var raceResponse = await client.DeleteAsync("/api/voice-packs/race-pack"); - RaceDeleteStatus = raceResponse.StatusCode; + var raceResponse = await client.DeleteAsync("/api/voice-packs/race-pack"); + RaceDeleteStatus = raceResponse.StatusCode; - // ── AC3 — retired/failed references never block ── - await InsertAdSpotAsync(database.StationConnectionString, state: "retired", voiceId: "af_cleanone", failReason: null); - await InsertAdSpotAsync(database.StationConnectionString, state: "failed", voiceId: "af_cleantwo", failReason: "render error"); + // ── AC3 — retired/failed references never block ── + await InsertAdSpotAsync(database.StationConnectionString, state: "retired", voiceId: "af_cleanone", failReason: null); + await InsertAdSpotAsync(database.StationConnectionString, state: "failed", voiceId: "af_cleantwo", failReason: "render error"); - var cleanResponse = await client.DeleteAsync("/api/voice-packs/clean-pack"); - CleanDeleteStatus = cleanResponse.StatusCode; - CleanPtFilesSurviveDelete = - File.Exists(Path.Combine(voicesRoot, "af_cleanone.pt")) || File.Exists(Path.Combine(voicesRoot, "af_cleantwo.pt")); - CleanPackAndVoiceRowsGone = !await PackRowExistsAsync(database.StationConnectionString, "clean-pack"); + var cleanResponse = await client.DeleteAsync("/api/voice-packs/clean-pack"); + CleanDeleteStatus = cleanResponse.StatusCode; + CleanPtFilesSurviveDelete = + File.Exists(Path.Combine(voicesRoot, "af_cleanone.pt")) || File.Exists(Path.Combine(voicesRoot, "af_cleantwo.pt")); + CleanPackAndVoiceRowsGone = !await PackRowExistsAsync(database.StationConnectionString, "clean-pack"); - // ── L2 — a slug that was never installed refuses 404, never a 204 "deleted" ── - var unknownSlugResponse = await client.DeleteAsync("/api/voice-packs/never-installed-pack"); - UnknownSlugDeleteStatus = unknownSlugResponse.StatusCode; - UnknownSlugDeleteBody = await unknownSlugResponse.Content.ReadAsStringAsync(); - } - finally - { - try { Directory.Delete(voicesRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } - } + // ── L2 — a slug that was never installed refuses 404, never a 204 "deleted" ── + var unknownSlugResponse = await client.DeleteAsync("/api/voice-packs/never-installed-pack"); + UnknownSlugDeleteStatus = unknownSlugResponse.StatusCode; + UnknownSlugDeleteBody = await unknownSlugResponse.Content.ReadAsStringAsync(); } static async Task InsertAdSpotAsync(string stationConnectionString, string state, string voiceId, string? failReason) @@ -552,60 +544,52 @@ public sealed class JinglePackUninstallArc : IAsyncLifetime public async Task InitializeAsync() { await using var database = await JinglePackUninstallDatabase.StartAsync(); - var jingleRoot = Directory.CreateTempSubdirectory("t414-story401-jingle-").FullName; - try + using var jingleRootDir = new TempDir(); + var jingleRoot = jingleRootDir.Path; + await SeedAdsLibraryAsync(database.LibraryConnectionString); + + await using var factory = new JinglePackUninstallWebFactory(database, jingleRoot); + + // F6.7 (T414 review round 2) — install/uninstall both sit behind + // [Authorize(Policy = AuthorizationPolicies.Settings)]; a client that never logged in must + // be turned away before either route runs. A slug that was never installed is fine here — + // auth denial happens in ASP.NET Core's own middleware, before the action (and so before + // any 404 the controller itself would answer) ever runs. + var unauthenticatedClient = factory.CreateClient(); + var unauthenticatedInstall = await unauthenticatedClient.PostAsync("/api/jingle-packs/never-installed-jingle-pack/install", null); + UnauthenticatedInstallStatus = unauthenticatedInstall.StatusCode; + var unauthenticatedDelete = await unauthenticatedClient.DeleteAsync("/api/jingle-packs/never-installed-jingle-pack"); + UnauthenticatedDeleteStatus = unauthenticatedDelete.StatusCode; + + var client = await JinglePackUninstallWebFactory.LoggedInClientAsync(factory); + + foreach (var slug in JinglePackUninstallFixtures.AllSlugs) { - await SeedAdsLibraryAsync(database.LibraryConnectionString); - - await using var factory = new JinglePackUninstallWebFactory(database, jingleRoot); - - // F6.7 (T414 review round 2) — install/uninstall both sit behind - // [Authorize(Policy = AuthorizationPolicies.Settings)]; a client that never logged in must - // be turned away before either route runs. A slug that was never installed is fine here — - // auth denial happens in ASP.NET Core's own middleware, before the action (and so before - // any 404 the controller itself would answer) ever runs. - var unauthenticatedClient = factory.CreateClient(); - var unauthenticatedInstall = await unauthenticatedClient.PostAsync("/api/jingle-packs/never-installed-jingle-pack/install", null); - UnauthenticatedInstallStatus = unauthenticatedInstall.StatusCode; - var unauthenticatedDelete = await unauthenticatedClient.DeleteAsync("/api/jingle-packs/never-installed-jingle-pack"); - UnauthenticatedDeleteStatus = unauthenticatedDelete.StatusCode; - - var client = await JinglePackUninstallWebFactory.LoggedInClientAsync(factory); - - foreach (var slug in JinglePackUninstallFixtures.AllSlugs) - { - var install = await client.PostAsync($"/api/jingle-packs/{slug}/install", null); - if (!install.IsSuccessStatusCode) - throw new InvalidOperationException( - $"fixture install of '{slug}' failed: {await install.Content.ReadAsStringAsync()}"); - } - - // ── AC4 — an active ad_spot's own bed_media_id blocks the guard pack ── - var bedMediaId = await ReadMediaIdAsync( - database.LibraryConnectionString, JinglePackUninstallFixtures.BedGuardSlug, JinglePackUninstallFixtures.BedGuardTitle); - BedGuardAdSpotId = await InsertAdSpotAsync(database.StationConnectionString, state: "approved", bedMediaId: bedMediaId); - - var bedGuardResponse = await client.DeleteAsync($"/api/jingle-packs/{JinglePackUninstallFixtures.BedGuardSlug}"); - BedGuardDeleteStatus = bedGuardResponse.StatusCode; - BedGuardDeleteBody = await bedGuardResponse.Content.ReadAsStringAsync(); - - // ── AC5 — no active reference uninstalls cleanly ── - var cleanResponse = await client.DeleteAsync($"/api/jingle-packs/{JinglePackUninstallFixtures.CleanSlug}"); - CleanDeleteStatus = cleanResponse.StatusCode; - CleanSlugFolderGone = !Directory.Exists(Path.Combine(jingleRoot, JinglePackUninstallFixtures.CleanSlug)); - CleanMediaRowsGone = await CountMediaRowsAsync(database.LibraryConnectionString, JinglePackUninstallFixtures.CleanSlug) == 0; - - // F7 (T414 review round 2) — a slug that was never installed refuses 404, never a 204. - var unknownSlugResponse = await client.DeleteAsync("/api/jingle-packs/never-installed-jingle-pack"); - UnknownSlugDeleteStatus = unknownSlugResponse.StatusCode; - UnknownSlugDeleteBody = await unknownSlugResponse.Content.ReadAsStringAsync(); - } - finally - { - try { Directory.Delete(jingleRoot, recursive: true); } - catch (IOException) { /* best-effort cleanup */ } - catch (UnauthorizedAccessException) { /* best-effort cleanup */ } + var install = await client.PostAsync($"/api/jingle-packs/{slug}/install", null); + if (!install.IsSuccessStatusCode) + throw new InvalidOperationException( + $"fixture install of '{slug}' failed: {await install.Content.ReadAsStringAsync()}"); } + + // ── AC4 — an active ad_spot's own bed_media_id blocks the guard pack ── + var bedMediaId = await ReadMediaIdAsync( + database.LibraryConnectionString, JinglePackUninstallFixtures.BedGuardSlug, JinglePackUninstallFixtures.BedGuardTitle); + BedGuardAdSpotId = await InsertAdSpotAsync(database.StationConnectionString, state: "approved", bedMediaId: bedMediaId); + + var bedGuardResponse = await client.DeleteAsync($"/api/jingle-packs/{JinglePackUninstallFixtures.BedGuardSlug}"); + BedGuardDeleteStatus = bedGuardResponse.StatusCode; + BedGuardDeleteBody = await bedGuardResponse.Content.ReadAsStringAsync(); + + // ── AC5 — no active reference uninstalls cleanly ── + var cleanResponse = await client.DeleteAsync($"/api/jingle-packs/{JinglePackUninstallFixtures.CleanSlug}"); + CleanDeleteStatus = cleanResponse.StatusCode; + CleanSlugFolderGone = !Directory.Exists(Path.Combine(jingleRoot, JinglePackUninstallFixtures.CleanSlug)); + CleanMediaRowsGone = await CountMediaRowsAsync(database.LibraryConnectionString, JinglePackUninstallFixtures.CleanSlug) == 0; + + // F7 (T414 review round 2) — a slug that was never installed refuses 404, never a 204. + var unknownSlugResponse = await client.DeleteAsync("/api/jingle-packs/never-installed-jingle-pack"); + UnknownSlugDeleteStatus = unknownSlugResponse.StatusCode; + UnknownSlugDeleteBody = await unknownSlugResponse.Content.ReadAsStringAsync(); } static async Task SeedAdsLibraryAsync(string libraryConnectionString) diff --git a/tests/GenWave.Host.Tests/Specs/Story405_WizardInstallsFirstBeds.cs b/tests/GenWave.Host.Tests/Specs/Story405_WizardInstallsFirstBeds.cs index e104739d..0af4c620 100644 --- a/tests/GenWave.Host.Tests/Specs/Story405_WizardInstallsFirstBeds.cs +++ b/tests/GenWave.Host.Tests/Specs/Story405_WizardInstallsFirstBeds.cs @@ -251,12 +251,12 @@ static void MakeExecutable(string path) static string MakeMediaDir(int flacCount) { - var dir = Directory.CreateTempSubdirectory("gw-setup-story405-media-").FullName; + var dir = TempDir.CreateForProcessLifetime(); for (var i = 0; i < flacCount; i++) File.WriteAllText(Path.Combine(dir, $"track{i}.flac"), ""); return dir; } - static string ScratchEnvDir() => Directory.CreateTempSubdirectory("gw-setup-story405-env-").FullName; + static string ScratchEnvDir() => TempDir.CreateForProcessLifetime(); static string ScratchEnvPath() => Path.Combine(ScratchEnvDir(), ".env"); @@ -277,8 +277,7 @@ static string ReadEnvValue(string envContent, string key) /// sibling it takes no optional argv-log path. static string WriteLaunchStub(int exitCode) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story405-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); File.WriteAllText(path, $"#!/usr/bin/env bash\nexit {exitCode}\n"); MakeExecutable(path); return path; @@ -290,8 +289,7 @@ static string WriteLaunchStub(int exitCode) /// one call is never affected, only install_first_beds's later cookie-jar mktemp is. static string WriteLaunchStubTouchingMarker(int exitCode, string markerFile) { - var path = Path.Combine( - Directory.CreateTempSubdirectory("gw-setup-story405-launch-").FullName, "launch-stub.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "launch-stub.sh"); // `: > "$file"` (a no-op builtin plus a redirect), not `touch` — kept even though // ScriptProcess.MakeBinDir's default toolset now includes touch, since this form needs // no PATH lookup at all. @@ -323,7 +321,7 @@ static Dictionary BaseEnv(string launchCmd, string streamUrl, in string binDir, string envFile, string stdinAnswers, IReadOnlyDictionary extraEnv, params string[] args) { - var scratchDir = Directory.CreateTempSubdirectory("gw-setup-story405-stdin-").FullName; + var scratchDir = TempDir.CreateForProcessLifetime(); var answersPath = Path.Combine(scratchDir, "answers.txt"); File.WriteAllText(answersPath, stdinAnswers); diff --git a/tests/GenWave.Host.Tests/Specs/Story424_PreviewRender.cs b/tests/GenWave.Host.Tests/Specs/Story424_PreviewRender.cs index e7d54130..677ac713 100644 --- a/tests/GenWave.Host.Tests/Specs/Story424_PreviewRender.cs +++ b/tests/GenWave.Host.Tests/Specs/Story424_PreviewRender.cs @@ -303,7 +303,7 @@ public sealed class Story424Collection : ICollectionFixture /// public sealed class Story424Arc : IAsyncLifetime { - string? authoredRoot; + TempDir? authoredRootDir; public HttpStatusCode PreviewEnqueueStatus { get; private set; } @@ -354,12 +354,12 @@ public async Task InitializeAsync() // A local, not a field — Story424Database is file-local (CS9051), the Story423Database // precedent one story over. await using var database = await Story424Database.StartAsync(); - authoredRoot = Directory.CreateTempSubdirectory("t442-story424-preview-").FullName; + authoredRootDir = new TempDir(); var adsLibraryId = await SeedAdsLibraryAsync(database.LibraryConnectionString); await SeedReadyBedRowAsync(database.LibraryConnectionString, adsLibraryId); - await using var factory = new Story424WebFactory(database, authoredRoot); + await using var factory = new Story424WebFactory(database, authoredRootDir.Path); var client = factory.CreateClient(); var login = await client.PostAsJsonAsync("/api/auth/login", new { password = Story424WebFactory.Password }); if (login.StatusCode != HttpStatusCode.NoContent) @@ -399,7 +399,7 @@ public async Task InitializeAsync() SettledPreviewPathFromSql = await AdSpotJobTestHelpers.ReadAdSpotPreviewPathAsync(database.StationConnectionString, spotId); ExpectedPreviewPath = SettledPreviewKey is null ? null - : Path.Combine(authoredRoot, "preview", $"{spotId}-{SettledPreviewKey}.wav"); + : Path.Combine(authoredRootDir.Path, "preview", $"{spotId}-{SettledPreviewKey}.wav"); var mediaCountAfter = await AdSpotJobTestHelpers.CountLibraryMediaRowsAsync(database.LibraryConnectionString); MediaRowCountUnchanged = mediaCountBefore == mediaCountAfter; @@ -499,8 +499,8 @@ public async Task InitializeAsync() EscapedPathArrangementSettled = escapedSettled; if (escapedSettled) { - var outsideDirectory = Directory.CreateTempSubdirectory("t442-preview-outside-").FullName; - var outsidePath = Path.Combine(outsideDirectory, "escape.wav"); + using var outsideDirectoryDir = new TempDir(); + var outsidePath = Path.Combine(outsideDirectoryDir.Path, "escape.wav"); await File.WriteAllBytesAsync(outsidePath, [1, 2, 3, 4]); await AdSpotJobTestHelpers.SetAdSpotPreviewPathAsync(database.StationConnectionString, escapedSpotId, outsidePath); @@ -509,7 +509,6 @@ public async Task InitializeAsync() var escapedWarning = factory.Logs.Messages.FirstOrDefault(m => m.Contains($"spot {escapedSpotId}", StringComparison.Ordinal)); EscapedPathWarningNamesTheSpotId = escapedWarning is not null; EscapedPathWarningOmitsTheRawPath = escapedWarning is not null && !escapedWarning.Contains(outsidePath, StringComparison.Ordinal); - Directory.Delete(outsideDirectory, recursive: true); } // ── A preview that rendered fine, streamed 200 once, then had its file @@ -534,8 +533,7 @@ public async Task InitializeAsync() public Task DisposeAsync() { - if (authoredRoot is not null && Directory.Exists(authoredRoot)) - Directory.Delete(authoredRoot, recursive: true); + authoredRootDir?.Dispose(); return Task.CompletedTask; } diff --git a/tests/GenWave.Host.Tests/Specs/Story425_ApprovePromotes.cs b/tests/GenWave.Host.Tests/Specs/Story425_ApprovePromotes.cs index 309e712b..0c5be112 100644 --- a/tests/GenWave.Host.Tests/Specs/Story425_ApprovePromotes.cs +++ b/tests/GenWave.Host.Tests/Specs/Story425_ApprovePromotes.cs @@ -225,8 +225,8 @@ public sealed class Story425Collection : ICollectionFixture /// public sealed class Story425Arc : IAsyncLifetime { - string? authoredRoot; - string? failureAuthoredRoot; + TempDir? authoredRootDir; + TempDir? failureAuthoredRootDir; public string SpotTitle { get; } = "Approve promotes"; public string AdsRootPrefix { get; private set; } = ""; @@ -279,18 +279,18 @@ public async Task InitializeAsync() // A local, not a field — Story425Database is file-local (CS9051), the Story424Database // precedent one story over. await using var database = await Story425Database.StartAsync(); - authoredRoot = Directory.CreateTempSubdirectory("t445-story425-approve-").FullName; - failureAuthoredRoot = Directory.CreateTempSubdirectory("t445-story425-approve-failure-").FullName; - AdsRootPrefix = Path.Combine(authoredRoot, "ads") + Path.DirectorySeparatorChar; + authoredRootDir = new TempDir(); + failureAuthoredRootDir = new TempDir(); + AdsRootPrefix = Path.Combine(authoredRootDir.Path, "ads") + Path.DirectorySeparatorChar; // AC5's own arrangement (STORY-425 AC5) — a regular FILE sitting exactly where // AdRenderService.PromotePreviewAsync's own Directory.CreateDirectory(adsRoot) must land, // reproducing even as CI's own root user (a bare permissions denial would not). - await File.WriteAllBytesAsync(Path.Combine(failureAuthoredRoot, "ads"), [0]); + await File.WriteAllBytesAsync(Path.Combine(failureAuthoredRootDir.Path, "ads"), [0]); await SeedAdsLibraryAsync(database.LibraryConnectionString); - await using var factory = new Story425WebFactory(database, authoredRoot); + await using var factory = new Story425WebFactory(database, authoredRootDir.Path); var client = factory.CreateClient(); await LoginAsync(client); @@ -311,8 +311,8 @@ public async Task InitializeAsync() var (spotId, _) = await AdSpotJobTestHelpers.CreateDraftSpotWithScriptAsync(client, sponsorId, SpotTitle, script); var key = ComputeCurrentPreviewKey(script, sponsor, liveSettings); - var previewPath = Path.Combine(authoredRoot, "preview", $"{spotId}-{key}.wav"); - Directory.CreateDirectory(Path.Combine(authoredRoot, "preview")); + var previewPath = Path.Combine(authoredRootDir.Path, "preview", $"{spotId}-{key}.wav"); + Directory.CreateDirectory(Path.Combine(authoredRootDir.Path, "preview")); await File.WriteAllBytesAsync(previewPath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync(database.StationConnectionString, spotId, previewPath, key, DateTime.UtcNow); PreviewPathBeforePromotion = previewPath; @@ -352,7 +352,7 @@ public async Task InitializeAsync() // by a rejected approve. ── var (staleSpotId, _) = await AdSpotJobTestHelpers.CreateDraftSpotWithScriptAsync( client, sponsorId, "Approve on stale preview", script); - var stalePreviewPath = Path.Combine(authoredRoot, "preview", $"{staleSpotId}-stale.wav"); + var stalePreviewPath = Path.Combine(authoredRootDir.Path, "preview", $"{staleSpotId}-stale.wav"); await File.WriteAllBytesAsync(stalePreviewPath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( database.StationConnectionString, staleSpotId, stalePreviewPath, new string('0', 64), DateTime.UtcNow); @@ -373,7 +373,7 @@ await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( var (missingFileSpotId, _) = await AdSpotJobTestHelpers.CreateDraftSpotWithScriptAsync( client, sponsorId, "Approve on missing preview file", script); var missingFileKey = ComputeCurrentPreviewKey(script, sponsor, liveSettings); - var missingFilePreviewPath = Path.Combine(authoredRoot, "preview", $"{missingFileSpotId}-{missingFileKey}.wav"); + var missingFilePreviewPath = Path.Combine(authoredRootDir.Path, "preview", $"{missingFileSpotId}-{missingFileKey}.wav"); await File.WriteAllBytesAsync(missingFilePreviewPath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( database.StationConnectionString, missingFileSpotId, missingFilePreviewPath, missingFileKey, DateTime.UtcNow); @@ -399,13 +399,13 @@ await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( var (escapedSpotId, _) = await AdSpotJobTestHelpers.CreateDraftSpotWithScriptAsync( client, sponsorId, "Approve on escaped preview path", script); var escapedKey = ComputeCurrentPreviewKey(script, sponsor, liveSettings); - var escapedPreviewPath = Path.Combine(authoredRoot, "preview", $"{escapedSpotId}-{escapedKey}.wav"); + var escapedPreviewPath = Path.Combine(authoredRootDir.Path, "preview", $"{escapedSpotId}-{escapedKey}.wav"); await File.WriteAllBytesAsync(escapedPreviewPath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( database.StationConnectionString, escapedSpotId, escapedPreviewPath, escapedKey, DateTime.UtcNow); - var outsideDirectory = Directory.CreateTempSubdirectory("t445-story425-preview-outside-").FullName; - var outsidePath = Path.Combine(outsideDirectory, "escape.wav"); + using var outsideDirectoryDir = new TempDir(); + var outsidePath = Path.Combine(outsideDirectoryDir.Path, "escape.wav"); await File.WriteAllBytesAsync(outsidePath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewPathAsync(database.StationConnectionString, escapedSpotId, outsidePath); @@ -431,19 +431,18 @@ await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( var escapedWarning = factory.Logs.Messages.FirstOrDefault(m => m.Contains($"spot {escapedSpotId}", StringComparison.Ordinal)); EscapedPathWarningNamesTheSpotId = escapedWarning is not null; EscapedPathWarningOmitsTheRawPath = escapedWarning is not null && !escapedWarning.Contains(outsidePath, StringComparison.Ordinal); - Directory.Delete(outsideDirectory, recursive: true); // ── AC5 — a landing failure falls back to Approved with no media row, no partial state — its // own WebFactory/authored-root pair (see this file's own header remarks). ── - await using var failureFactory = new Story425WebFactory(database, failureAuthoredRoot); + await using var failureFactory = new Story425WebFactory(database, failureAuthoredRootDir.Path); var failureClient = failureFactory.CreateClient(); await LoginAsync(failureClient); var (failureSpotId, _) = await AdSpotJobTestHelpers.CreateDraftSpotWithScriptAsync( failureClient, sponsorId, "Approve landing failure", script); var failureKey = ComputeCurrentPreviewKey(script, sponsor, liveSettings); - var failurePreviewPath = Path.Combine(failureAuthoredRoot, "preview", $"{failureSpotId}-{failureKey}.wav"); - Directory.CreateDirectory(Path.Combine(failureAuthoredRoot, "preview")); + var failurePreviewPath = Path.Combine(failureAuthoredRootDir.Path, "preview", $"{failureSpotId}-{failureKey}.wav"); + Directory.CreateDirectory(Path.Combine(failureAuthoredRootDir.Path, "preview")); await File.WriteAllBytesAsync(failurePreviewPath, BuildMinimalWavBytes()); await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( database.StationConnectionString, failureSpotId, failurePreviewPath, failureKey, DateTime.UtcNow); @@ -461,15 +460,13 @@ await AdSpotJobTestHelpers.SetAdSpotPreviewStampAsync( FailureRowPreviewKey = failureRow.PreviewKey; FailureRowPreviewAt = failureRow.PreviewAt; FailureLandedMediaRowCount = await AdSpotJobTestHelpers.CountLibraryMediaRowsUnderPathAsync( - database.LibraryConnectionString, Path.Combine(failureAuthoredRoot, "ads")); + database.LibraryConnectionString, Path.Combine(failureAuthoredRootDir.Path, "ads")); } public Task DisposeAsync() { - if (authoredRoot is not null && Directory.Exists(authoredRoot)) - Directory.Delete(authoredRoot, recursive: true); - if (failureAuthoredRoot is not null && Directory.Exists(failureAuthoredRoot)) - Directory.Delete(failureAuthoredRoot, recursive: true); + authoredRootDir?.Dispose(); + failureAuthoredRootDir?.Dispose(); return Task.CompletedTask; } diff --git a/tests/GenWave.Host.Tests/Specs/Story436_LaunchStopsOnFailedMigration.cs b/tests/GenWave.Host.Tests/Specs/Story436_LaunchStopsOnFailedMigration.cs index 0be14cc8..bae3448e 100644 --- a/tests/GenWave.Host.Tests/Specs/Story436_LaunchStopsOnFailedMigration.cs +++ b/tests/GenWave.Host.Tests/Specs/Story436_LaunchStopsOnFailedMigration.cs @@ -69,7 +69,7 @@ static string MakeBinDir() static string MakeScratchRepo(bool plantFailingMigration) { var root = RepoRootLocator.Find(AppContext.BaseDirectory); - var scratch = Directory.CreateTempSubdirectory("story436-repo-").FullName; + var scratch = TempDir.CreateForProcessLifetime(); foreach (var entry in Directory.EnumerateFileSystemEntries(root)) { var name = Path.GetFileName(entry); @@ -96,7 +96,7 @@ static string MakeScratchRepo(bool plantFailingMigration) /// The six required secrets, via preflight's GW_ENV_FILE seam — never the real .env. static string WriteEnvFile() { - var path = Path.Combine(Directory.CreateTempSubdirectory("story436-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, [ "POSTGRES_PASSWORD=x", "LIBRARY_DB_PASSWORD=x", "STATION_DB_PASSWORD=x", @@ -125,7 +125,7 @@ static Run RunLaunch(bool plantFailingMigration, params string[] args) { var bin = MakeBinDir(); var scratch = MakeScratchRepo(plantFailingMigration); - var log = Path.Combine(Directory.CreateTempSubdirectory("story436-log-").FullName, "docker.log"); + var log = Path.Combine(TempDir.CreateForProcessLifetime(), "docker.log"); var extraEnv = new Dictionary { diff --git a/tests/GenWave.Host.Tests/Specs/Story437_BuildWithoutLaunchPrereqs.cs b/tests/GenWave.Host.Tests/Specs/Story437_BuildWithoutLaunchPrereqs.cs index 254fcc81..acbe6540 100644 --- a/tests/GenWave.Host.Tests/Specs/Story437_BuildWithoutLaunchPrereqs.cs +++ b/tests/GenWave.Host.Tests/Specs/Story437_BuildWithoutLaunchPrereqs.cs @@ -87,7 +87,7 @@ static string MakeBinDir(string dockerStub = DockerStubHealthy, bool withDocker static string MakeScratchRepo() { var root = RepoRootLocator.Find(AppContext.BaseDirectory); - var scratch = Directory.CreateTempSubdirectory("story437-repo-").FullName; + var scratch = TempDir.CreateForProcessLifetime(); foreach (var entry in Directory.EnumerateFileSystemEntries(root)) { var name = Path.GetFileName(entry); @@ -121,7 +121,7 @@ public IReadOnlyDictionary EnvSeenBy(string tool, string firstAr static Run RunScript(string script, string bin, IReadOnlyDictionary? extraEnv = null, params string[] args) { var scratch = MakeScratchRepo(); - var log = Path.Combine(Directory.CreateTempSubdirectory("story437-log-").FullName, "tools.log"); + var log = Path.Combine(TempDir.CreateForProcessLifetime(), "tools.log"); var mergedEnv = new Dictionary { ["GW_TOOL_LOG"] = log }; if (extraEnv is not null) @@ -162,7 +162,7 @@ . tools/preflight.sh static string WriteEnvFile() { - var path = Path.Combine(Directory.CreateTempSubdirectory("story437-env-").FullName, "test.env"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); File.WriteAllLines(path, [ "POSTGRES_PASSWORD=x", "LIBRARY_DB_PASSWORD=x", "STATION_DB_PASSWORD=x", diff --git a/tests/GenWave.Host.Tests/Specs/Story438_ScriptEnvIsolation.cs b/tests/GenWave.Host.Tests/Specs/Story438_ScriptEnvIsolation.cs index 66b9b92a..cbb86383 100644 --- a/tests/GenWave.Host.Tests/Specs/Story438_ScriptEnvIsolation.cs +++ b/tests/GenWave.Host.Tests/Specs/Story438_ScriptEnvIsolation.cs @@ -90,7 +90,7 @@ static void AddStub(string bin, string name, string body) static string MakeBinDir(params string[] tools) { - var dir = Directory.CreateTempSubdirectory("story438-bin-").FullName; + var dir = TempDir.CreateForProcessLifetime(); foreach (var tool in tools) File.CreateSymbolicLink(Path.Combine(dir, tool), ResolveTool(tool)); return dir; @@ -99,7 +99,7 @@ static string MakeBinDir(params string[] tools) /// A script that prints the child's view of the variables under test, one NAME=value per line. static string WriteProbeScript() { - var path = Path.Combine(Directory.CreateTempSubdirectory("story438-probe-").FullName, "probe.sh"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "probe.sh"); File.WriteAllText(path, """ #!/usr/bin/env bash for name in GW_SPEC_CANARY_438 SKIP_SPEC_CANARY_438 COMPOSE_SPEC_CANARY_438 \ @@ -218,7 +218,7 @@ public static void Tmpdir_is_kept() public static class ScenarioAnExplicitOverrideStillFlowsThrough { - static readonly string EnvFile = Path.Combine(Directory.CreateTempSubdirectory("story438-env-").FullName, "test.env"); + static readonly string EnvFile = Path.Combine(TempDir.CreateForProcessLifetime(), "test.env"); static readonly Lazy Probe = new(() => { @@ -250,7 +250,7 @@ public static class ScenarioTheLaunchScriptUnderTheHelperRunsItsPreflight case "$*" in *" version"*) echo "Docker Compose version v2.29.0" ;; esac exit 0 """); - var scratch = Directory.CreateTempSubdirectory("story438-repo-").FullName; + var scratch = TempDir.CreateForProcessLifetime(); foreach (var entry in Directory.EnumerateFileSystemEntries(RepoRoot)) { var name = Path.GetFileName(entry); diff --git a/tests/GenWave.Host.Tests/Specs/Story441_TempDirsCleanUp.cs b/tests/GenWave.Host.Tests/Specs/Story441_TempDirsCleanUp.cs index 0882c986..2df50a3a 100644 --- a/tests/GenWave.Host.Tests/Specs/Story441_TempDirsCleanUp.cs +++ b/tests/GenWave.Host.Tests/Specs/Story441_TempDirsCleanUp.cs @@ -3,8 +3,8 @@ // BDD specification — xUnit. AC1–AC3 drive TempDir; AC5–AC7 drive TempSweep.Run over a scratch root // (never the real temp root); AC4 and AC8 are source pins over tests/GenWave.Host.Tests. // -// RED at plan time: TempDir/TempSweep are throwing skeletons; 28 files still call -// Directory.CreateTempSubdirectory directly. +// Was red at plan time (throwing skeletons, 28 files creating their own scratch directories); +// green since T479/T480. using GenWave.Host.Tests.Support; @@ -12,8 +12,9 @@ namespace GenWave.Host.Tests.Specs; public static class FeatureTestTempDirectoriesCleanUpAfterThemselves { - const string PendingType = "pending: T479 — TempDir + TempSweep + the once-per-process sweep (STORY-441)"; - const string PendingCallSites = "pending: T480 — every CreateTempSubdirectory call site moves onto TempDir (STORY-441)"; + // Split so this file's own source text never trips the scan below (which greps for the very + // same API name it is asserting nothing but TempDir.cs still calls). + const string Needle = "CreateTemp" + "Subdirectory"; static string HostTestsDir => Path.Combine(RepoRootLocator.Find(AppContext.BaseDirectory), "tests", "GenWave.Host.Tests"); @@ -32,7 +33,7 @@ public ScenarioANewTempDirLivesUnderTheTempRoot() path = dir.Path; } - [Fact(Skip = PendingType)] + [Fact] public void StartsWithTheTempRootAndPrefix() => Assert.StartsWith(Path.Combine(Path.GetTempPath(), TempDir.Prefix), path, StringComparison.Ordinal); } @@ -50,7 +51,7 @@ public ScenarioDisposeDeletesRecursively() dir.Dispose(); } - [Fact(Skip = PendingType)] + [Fact] public void TheDirectoryIsGone() => Assert.False(Directory.Exists(path)); } @@ -58,41 +59,42 @@ public ScenarioDisposeDeletesRecursively() // HAPPY PATH — TempSweep // --------------------------------------------------------------------- - public sealed class ScenarioTheSweepRemovesStaleAndKeepsFresh + public sealed class ScenarioTheSweepRemovesStaleAndKeepsFresh : IDisposable { - readonly string root; + readonly TempDir root = new(); public ScenarioTheSweepRemovesStaleAndKeepsFresh() { - root = Directory.CreateTempSubdirectory("story441-root-").FullName; var stale = DateTime.UtcNow - TimeSpan.FromHours(2); foreach (var name in new[] { "gw-stale", "genwave-pawire-x", "story343-env-x", "gh332-x", "unrelated-x" }) { - var dir = Directory.CreateDirectory(Path.Combine(root, name)); + var dir = Directory.CreateDirectory(Path.Combine(root.Path, name)); Directory.SetLastWriteTimeUtc(dir.FullName, stale); } - Directory.CreateDirectory(Path.Combine(root, "gw-fresh")); + Directory.CreateDirectory(Path.Combine(root.Path, "gw-fresh")); - TempSweep.Run(root, olderThan: TimeSpan.FromHours(1)); + TempSweep.Run(root.Path, olderThan: TimeSpan.FromHours(1)); } - [Fact(Skip = PendingType)] - public void GwStaleIsGone() => Assert.False(Directory.Exists(Path.Combine(root, "gw-stale"))); + public void Dispose() => root.Dispose(); - [Fact(Skip = PendingType)] - public void GwFreshRemains() => Assert.True(Directory.Exists(Path.Combine(root, "gw-fresh"))); + [Fact] + public void GwStaleIsGone() => Assert.False(Directory.Exists(Path.Combine(root.Path, "gw-stale"))); - [Fact(Skip = PendingType)] - public void PawireIsGone() => Assert.False(Directory.Exists(Path.Combine(root, "genwave-pawire-x"))); + [Fact] + public void GwFreshRemains() => Assert.True(Directory.Exists(Path.Combine(root.Path, "gw-fresh"))); - [Fact(Skip = PendingType)] - public void Story343EnvIsGone() => Assert.False(Directory.Exists(Path.Combine(root, "story343-env-x"))); + [Fact] + public void PawireIsGone() => Assert.False(Directory.Exists(Path.Combine(root.Path, "genwave-pawire-x"))); - [Fact(Skip = PendingType)] - public void Gh332IsGone() => Assert.False(Directory.Exists(Path.Combine(root, "gh332-x"))); + [Fact] + public void Story343EnvIsGone() => Assert.False(Directory.Exists(Path.Combine(root.Path, "story343-env-x"))); - [Fact(Skip = PendingType)] - public void UnrelatedRemains() => Assert.True(Directory.Exists(Path.Combine(root, "unrelated-x"))); + [Fact] + public void Gh332IsGone() => Assert.False(Directory.Exists(Path.Combine(root.Path, "gh332-x"))); + + [Fact] + public void UnrelatedRemains() => Assert.True(Directory.Exists(Path.Combine(root.Path, "unrelated-x"))); } public sealed class ScenarioTheSweepRunsOncePerProcess @@ -105,18 +107,18 @@ public sealed class ScenarioTheSweepRunsOncePerProcess .Where(f => File.ReadAllText(f).Contains("TempSweep.Run(Path.GetTempPath()", StringComparison.Ordinal)) .ToArray(); - [Fact(Skip = PendingType)] + [Fact] public void ExactlyOneInitializerCallsTheSweep() => Assert.Single(callers); } - public sealed class ScenarioNoSpecCallsCreateTempSubdirectoryDirectly + public sealed class ScenarioNoSpecBypassesTempDir { readonly string[] hits = Directory.EnumerateFiles(HostTestsDir, "*.cs", SearchOption.AllDirectories) - .Where(f => File.ReadAllText(f).Contains("CreateTempSubdirectory", StringComparison.Ordinal)) + .Where(f => File.ReadAllText(f).Contains(Needle, StringComparison.Ordinal)) .Select(f => Path.GetRelativePath(HostTestsDir, f)) .ToArray(); - [Fact(Skip = PendingCallSites)] + [Fact] public void TheOnlyHitIsTempDir() => Assert.Equal([Path.Combine("Support", "TempDir.cs")], hits); } @@ -136,7 +138,7 @@ public ScenarioDisposeSwallowsAVanishedDirectory() caught = Record.Exception(dir.Dispose); } - [Fact(Skip = PendingType)] + [Fact] public void NoExceptionEscapes() => Assert.Null(caught); } } diff --git a/tests/GenWave.Host.Tests/Specs/Story445_CapturedAudio.cs b/tests/GenWave.Host.Tests/Specs/Story445_CapturedAudio.cs index 0694a42d..762f5efa 100644 --- a/tests/GenWave.Host.Tests/Specs/Story445_CapturedAudio.cs +++ b/tests/GenWave.Host.Tests/Specs/Story445_CapturedAudio.cs @@ -44,24 +44,26 @@ static double IntegratedLufs(string file) // HAPPY PATH — the synthesised media // --------------------------------------------------------------------- - public sealed class ScenarioTheSynthesisedTracksHitTheirTargets + public sealed class ScenarioTheSynthesisedTracksHitTheirTargets : IDisposable { - readonly string outDir = Directory.CreateTempSubdirectory("story445-media-").FullName; + readonly TempDir outDir = new(); readonly int exitCode; public ScenarioTheSynthesisedTracksHitTheirTargets() => - exitCode = ScriptProcess.Run(Path.Combine(Repo, "tools", "gate", "make_media.sh"), RealPath, null, null, outDir).ExitCode; + exitCode = ScriptProcess.Run(Path.Combine(Repo, "tools", "gate", "make_media.sh"), RealPath, null, null, outDir.Path).ExitCode; + + public void Dispose() => outDir.Dispose(); [Fact(Skip = PendingMedia)] public void TheScriptSucceeds() => Assert.Equal(0, exitCode); [Fact(Skip = PendingMedia)] public void TheLoudTrackIsAtMinusTwelve() => - Assert.InRange(IntegratedLufs(Path.Combine(outDir, "tone-loud.mp3")), -13, -11); + Assert.InRange(IntegratedLufs(Path.Combine(outDir.Path, "tone-loud.mp3")), -13, -11); [Fact(Skip = PendingMedia)] public void TheQuietTrackIsAtMinusThirty() => - Assert.InRange(IntegratedLufs(Path.Combine(outDir, "tone-quiet.mp3")), -31, -29); + Assert.InRange(IntegratedLufs(Path.Combine(outDir.Path, "tone-quiet.mp3")), -31, -29); } public sealed class ScenarioTheCommittedClipsAreSmallAndSourced diff --git a/tests/GenWave.Host.Tests/Specs/Story449_NightlyWorkflow.cs b/tests/GenWave.Host.Tests/Specs/Story449_NightlyWorkflow.cs index 71f97485..42dd590e 100644 --- a/tests/GenWave.Host.Tests/Specs/Story449_NightlyWorkflow.cs +++ b/tests/GenWave.Host.Tests/Specs/Story449_NightlyWorkflow.cs @@ -46,7 +46,7 @@ static string[] Report(string integration, string chaos, string? openIssue) { var bin = ScriptProcess.MakeBinDir("jq"); ScriptProcess.AddStub(bin, "gh", GhStub); - var log = Path.Combine(Directory.CreateTempSubdirectory("story449-log-").FullName, "gh.log"); + var log = Path.Combine(TempDir.CreateForProcessLifetime(), "gh.log"); var env = new Dictionary { ["GATE_STUB_LOG"] = log, diff --git a/tests/GenWave.Host.Tests/Support/CrosstalkWorkerHarness.cs b/tests/GenWave.Host.Tests/Support/CrosstalkWorkerHarness.cs index 9e27ac4f..d2686e15 100644 --- a/tests/GenWave.Host.Tests/Support/CrosstalkWorkerHarness.cs +++ b/tests/GenWave.Host.Tests/Support/CrosstalkWorkerHarness.cs @@ -120,39 +120,18 @@ internal static class CrosstalkWorkerHarness { /// /// Hygiene fix (round-N review — the leaked-temp-dir finding): every call - /// used to hand a FRESH Directory.CreateTempSubdirectory - /// root of its own, straight under the OS temp directory, with nothing ever deleting it — hundreds - /// of orphaned crosstalk-worker-test-* directories accumulate on a box that has run this - /// suite repeatedly (this file alone is called from three spec files, several times each), eventually - /// exhausting tmpfs inodes and silently redding unrelated facts across the whole test run. ONE - /// shared root for the WHOLE test process instead, created lazily on first use; each - /// call gets its own uniquely-named SUBdirectory underneath it, and - /// deletes the entire root, recursively, exactly once, when the + /// used to hand a FRESH scratch directory of its own, straight + /// under the OS temp directory, with nothing ever deleting it — hundreds of orphaned directories + /// accumulate on a box that has run this suite repeatedly (this file alone is called from three + /// spec files, several times each), eventually exhausting tmpfs inodes and silently redding + /// unrelated facts across the whole test run. ONE shared root for the WHOLE test process instead + /// (), created lazily on first use; each + /// call gets its own uniquely-named SUBdirectory underneath it, and the + /// shared root's own disposal deletes everything beneath it, recursively, exactly once, when the /// test host process itself ends — no per-call disposal for every one of the many call sites across /// Story328/Story353/Story354 to thread through. /// - static readonly string SharedTempRoot = CreateSharedTempRoot(); - - static string CreateSharedTempRoot() - { - var root = Directory.CreateTempSubdirectory("crosstalk-worker-tests-").FullName; - AppDomain.CurrentDomain.ProcessExit += (_, _) => - { - try - { - Directory.Delete(root, recursive: true); - } - catch (IOException) - { - // Best-effort cleanup — a stray open handle at process teardown never fails the run. - } - catch (UnauthorizedAccessException) - { - // Same — teardown ordering is not guaranteed, so this is advisory, not load-bearing. - } - }; - return root; - } + static readonly string SharedTempRoot = TempDir.CreateForProcessLifetime(); static readonly string WellFormedReply = string.Join('\n', new[] { diff --git a/tests/GenWave.Host.Tests/Support/GateHarness.cs b/tests/GenWave.Host.Tests/Support/GateHarness.cs index 98526827..9c4e1c1e 100644 --- a/tests/GenWave.Host.Tests/Support/GateHarness.cs +++ b/tests/GenWave.Host.Tests/Support/GateHarness.cs @@ -111,7 +111,7 @@ public static string MakeBinDir(IReadOnlyDictionary? extraStubs public static string MakeRepoCopy() { var root = RepoRootLocator.Find(AppContext.BaseDirectory); - var copy = Directory.CreateTempSubdirectory("story444-repo-").FullName; + var copy = TempDir.CreateForProcessLifetime(); foreach (var entry in Directory.EnumerateFileSystemEntries(root)) { var name = Path.GetFileName(entry); @@ -154,8 +154,8 @@ public static Run Execute(FakeStation station, string? bin = null, { bin ??= MakeBinDir(); var copy = MakeRepoCopy(); - var log = Path.Combine(Directory.CreateTempSubdirectory("story444-log-").FullName, "stub.log"); - var report = Path.Combine(Directory.CreateTempSubdirectory("story444-report-").FullName, "out"); + var log = Path.Combine(TempDir.CreateForProcessLifetime(), "stub.log"); + var report = Path.Combine(TempDir.CreateForProcessLifetime(), "out"); var extraEnv = new Dictionary { @@ -286,7 +286,7 @@ public void Dispose() /// optionally with a 3-second digital-silence gap in the middle. public static string MakeWav(double lufs, int seconds = 6, bool withGap = false) { - var path = Path.Combine(Directory.CreateTempSubdirectory("story445-wav-").FullName, "tone.wav"); + var path = Path.Combine(TempDir.CreateForProcessLifetime(), "tone.wav"); var half = seconds / 2; var graph = withGap ? $"sine=frequency=440:duration={half},loudnorm=I={lufs}:TP=-1:LRA=7[a];anullsrc=r=48000:cl=mono:d=3[s];sine=frequency=440:duration={half},loudnorm=I={lufs}:TP=-1:LRA=7[b];[a][s][b]concat=n=3:v=0:a=1" diff --git a/tests/GenWave.Host.Tests/Support/ScriptProcess.cs b/tests/GenWave.Host.Tests/Support/ScriptProcess.cs index 1fb2dc15..cb613266 100644 --- a/tests/GenWave.Host.Tests/Support/ScriptProcess.cs +++ b/tests/GenWave.Host.Tests/Support/ScriptProcess.cs @@ -164,7 +164,7 @@ static string ResolveTool(string tool) /// symlinked in and nothing else — a scenario adds a scripted stub on top with . public static string MakeBinDir(params string[] extraTools) { - var dir = Directory.CreateTempSubdirectory("gw-script-bin-").FullName; + var dir = TempDir.CreateForProcessLifetime(); foreach (var tool in BaseTools.Concat(extraTools).Distinct(StringComparer.Ordinal)) File.CreateSymbolicLink(Path.Combine(dir, tool), ResolveTool(tool)); return dir; diff --git a/tests/GenWave.Host.Tests/Support/TempDir.cs b/tests/GenWave.Host.Tests/Support/TempDir.cs index 488dd09e..0a8c74a4 100644 --- a/tests/GenWave.Host.Tests/Support/TempDir.cs +++ b/tests/GenWave.Host.Tests/Support/TempDir.cs @@ -5,16 +5,47 @@ namespace GenWave.Host.Tests.Support; /// created under with the gw- prefix, deleted /// recursively on , tolerant of a directory that already vanished. /// -/// Skeleton at plan time — throws until T479 lands. internal sealed class TempDir : IDisposable { public const string Prefix = "gw-"; - public TempDir() => - throw new NotImplementedException("pending: T479 — TempDir (STORY-441)"); + bool disposed; - public string Path => throw new NotImplementedException("pending: T479 — TempDir (STORY-441)"); + public TempDir() => Path = Directory.CreateTempSubdirectory(Prefix).FullName; - public void Dispose() => - throw new NotImplementedException("pending: T479 — TempDir (STORY-441)"); + public string Path { get; } + + /// + /// A scratch directory whose lifetime is the whole test process rather than one using + /// block — the third lifetime shape after using var and fixture-owned — for a static helper that hands a fresh path to many call sites + /// across a spec file — or across several — threading a per-call disposer through every one + /// of them would balloon the diff for no behavioural gain. runs once, + /// at , best-effort; a killed process leaves the directory + /// for the startup TempSweep to reclaim, exactly like every other gw-* scratch + /// dir. + /// + public static string CreateForProcessLifetime() + { + var dir = new TempDir(); + AppDomain.CurrentDomain.ProcessExit += (_, _) => dir.Dispose(); + return dir.Path; + } + + public void Dispose() + { + if (disposed) + return; + + disposed = true; + + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception ex) when (ex is DirectoryNotFoundException or IOException or UnauthorizedAccessException) + { + // Already gone, or a transient handle elsewhere — nothing left for a test-scratch + // directory's disposal to do. + } + } } diff --git a/tests/GenWave.Host.Tests/Support/TempSweep.cs b/tests/GenWave.Host.Tests/Support/TempSweep.cs index 917af90f..857f97b4 100644 --- a/tests/GenWave.Host.Tests/Support/TempSweep.cs +++ b/tests/GenWave.Host.Tests/Support/TempSweep.cs @@ -5,12 +5,40 @@ namespace GenWave.Host.Tests.Support; /// directly under root whose name starts with one of and whose last /// write is older than olderThan. Run once per Host.Tests process from a module initializer. /// -/// Skeleton at plan time — throws until T479 lands. internal static class TempSweep { public static readonly string[] Prefixes = ["gw-", "genwave-pawire-", "story343-env-", "gh332-"]; /// Returns the number of directories removed. - public static int Run(string root, TimeSpan olderThan) => - throw new NotImplementedException("pending: T479 — TempSweep.Run (STORY-441)"); + public static int Run(string root, TimeSpan olderThan) + { + if (!Directory.Exists(root)) + return 0; + + var cutoff = DateTime.UtcNow - olderThan; + var removed = 0; + + foreach (var directory in Directory.EnumerateDirectories(root)) + { + var name = Path.GetFileName(directory); + if (!Prefixes.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))) + continue; + + if (Directory.GetLastWriteTimeUtc(directory) >= cutoff) + continue; + + try + { + Directory.Delete(directory, recursive: true); + removed++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Another process still holds this one open (or lost a race deleting it) — + // leave it for the next sweep rather than failing the whole run over it. + } + } + + return removed; + } } diff --git a/tests/GenWave.Host.Tests/TempSweepModuleInitializer.cs b/tests/GenWave.Host.Tests/TempSweepModuleInitializer.cs new file mode 100644 index 00000000..d7372cba --- /dev/null +++ b/tests/GenWave.Host.Tests/TempSweepModuleInitializer.cs @@ -0,0 +1,40 @@ +using System.Runtime.CompilerServices; +using GenWave.Host.Tests.Support; + +namespace GenWave.Host.Tests; + +/// +/// Sweeps once per Host.Tests process (gh-#710, STORY-441, PLAN +/// T479): the dev box accumulated ~60k gw-* scratch directories because a spec that creates +/// its own fresh scratch directory has no guaranteed cleanup path when a run is killed mid-test (a +/// debugger stop, a CI cancellation, a crash) — never runs, and the +/// OS never reclaims /tmp on its own between container runs. +/// +/// Removes every directory under the real temp root whose name starts with (gw-, genwave-pawire-, story343-env-, +/// gh332-) and is more than an hour old. The one-hour floor is deliberate, not arbitrary: +/// a sibling Host.Tests process running concurrently on the same box (a second CI shard, a +/// developer's parallel `dotnet test` invocation) may have fresh directories of its own under +/// those same prefixes, and this sweep must never delete out from under a suite that is still +/// running — only directories old enough to belong to a run that has already finished are fair +/// game. +/// +/// Wrapped in a swallow-everything try/catch: a sweep is best-effort housekeeping, never a test +/// dependency, so a failure here (permissions, a raced deletion, anything) must not fail the +/// suite it is trying to keep tidy. +/// +static class TempSweepModuleInitializer +{ + [ModuleInitializer] + internal static void Initialize() + { + try + { + TempSweep.Run(Path.GetTempPath(), TimeSpan.FromHours(1)); + } + catch (Exception) + { + // Best-effort cleanup only — never allowed to fail the suite it is tidying up after. + } + } +}