From 5e1a400db93d86d09130df7248cb2324fc37ba65 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 21 Aug 2026 22:45:57 +0000 Subject: [PATCH] chore: clear the CodeQL backlog and add the codeql.yml query-filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the repo for NextIteration.Standards §4.12, which requires a code_scanning merge-protection rule at alerts_threshold: all — every open alert blocks merge, so the backlog has to be resolved or dismissed first. §3.0.1 / §4.4 — codeql.yml gains the canonical query-filters block excluding cs/unmanaged-code and cs/call-to-unmanaged-code. This package has no P/Invoke so it matches nothing; it is carried because the workflow must equal the template in non-comment content. Closes the `3.0.1 workflow content` row. Genuine fixes, no suppression hacks: - Path.Combine -> Path.Join, 134 sites across src and tests. Combine returns its last rooted argument and drops what precedes it; Join always concatenates. ValidateAssetName already rejects rooted and separator-bearing asset names, so this is defence in depth, not a live-defect fix. - cs/linq/missed-where + missed-select: DefaultAssetResolver's five matcher loops become FirstOrDefault/Any predicates (the RID walk stays lazy), Sha256SumsManifest.Parse maps with Select, UpdateInstaller.IsPreserved filters with Where/Any behind a named helper. - cs/local-not-disposed: ten test-local TestConsole instances now use `using`. - cs/useless-assignment-to-local: dropped the unread `first` binding. Three cs/local-not-disposed sites are deliberately NOT fixed and now carry a comment saying why: the console escapes its harness method via the return value and the calling test reads console.Output afterwards, so disposing at the creation site closes the writer before the command writes to it. Applying `using` there failed 46 tests with ObjectDisposedException. The 19 cs/catch-of-all-exceptions alerts are all deliberate boundary or best-effort catches, several of them contractual — IUpdateSource's XML docs require implementations to swallow transient failures and return null, and CleanupOldInstall documents that failures are swallowed and retried. Narrowing them would break the documented contract, so they are for dismissal, not fixes. Release build: 0 warnings. 392 tests (196 × net8.0/net10.0) pass. Co-Authored-By: Claude Opus 5 --- .github/workflows/codeql.yml | 14 ++ CHANGELOG.md | 6 + .../Pipeline/UpdateChecker.cs | 8 +- .../Pipeline/UpdateInstaller.cs | 69 +++--- .../Resolution/DefaultAssetResolver.cs | 65 ++---- .../Sources/GhCliReleaseSource.cs | 2 +- .../Verification/Sha256SumsManifest.cs | 3 +- .../CommandConfiguratorExtensionsTests.cs | 3 + .../Commands/UpdateCheckCommandTests.cs | 3 + .../Commands/UpdateCommandTests.cs | 3 + .../Infrastructure/TempDir.cs | 4 +- .../Pipeline/ArchiveExtractorTests.cs | 10 +- .../Pipeline/InstallLockTests.cs | 12 +- .../Pipeline/UpdateInstallerTests.cs | 200 +++++++++--------- .../UpdateBannerTests.cs | 12 +- .../UpdateCleanupTests.cs | 12 +- 16 files changed, 206 insertions(+), 220 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7177a68..4e37bc7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,10 +49,24 @@ jobs: # Analyse source only. obj/ and bin/ hold generated and compiled # output — e.g. the xUnit auto-generated entry point — so findings # there are noise against code no human maintains. + # + # query-filters excludes the two audit queries that fire on every + # P/Invoke declaration and call site (cs/unmanaged-code, + # cs/call-to-unmanaged-code). Native-backend packages (Keychain, + # libsecret, DPAPI) exist to call unmanaged code, so these are pure + # noise there and non-native repos have no P/Invoke for them to hit. + # This excludes ONLY those two queries — every other + # security-and-quality query still runs on the interop files, so no + # real finding is lost (STANDARD.md 4.4). config: | paths-ignore: - "**/obj/**" - "**/bin/**" + query-filters: + - exclude: + id: cs/unmanaged-code + - exclude: + id: cs/call-to-unmanaged-code # Explicit build rather than autobuild: these repos multi-target, and # autobuild has picked a single TFM in the past, silently analysing half diff --git a/CHANGELOG.md b/CHANGELOG.md index 61689e1..e81c289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Adopted the revised canonical `.editorconfig` and enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §5.2, §1.2.1 — the latter now a `MUST`). The canonical file is a deliberate allow-list of gated style rules rather than a blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a future SDK ships never auto-gates the build. With the flag on, the gated rules fail the build under `TreatWarningsAsErrors` instead of merely showing in the IDE. Bringing the code green was a mechanical, behaviour-preserving reformat of 92 sites — braces on all single-statement `if`s (IDE0011, 64 of them), collection expressions (IDE0300/IDE0301/IDE0028), `var` usage, two expression-bodied members, one simplified null check, and five unnecessary usings — applied with `dotnet format` plus the collection-expression sites it cannot fix automatically. All 392 tests (196 × `net8.0`/`net10.0`) pass unchanged, and the build stays at zero warnings. +- **`Path.Combine` → `Path.Join` repo-wide** (134 call sites, `src` and `tests`). `Path.Combine` returns its *last rooted argument* and silently discards everything before it, so a rooted second segment escapes the directory the first argument names. `Path.Join` always concatenates. Nothing here was reachable with a rooted segment — `ValidateAssetName` already rejects rooted and separator-bearing asset names before any path is built — so this is defence in depth on the install-directory path construction rather than a fix for a live defect. Verified behaviour-preserving: all 392 tests pass unchanged. +- **Idiomatic LINQ in place of filter-style loops.** `DefaultAssetResolver`'s five matcher loops become `FirstOrDefault`/`Any` predicates (the RID candidate walk stays lazy — `Select` plus `FirstOrDefault` still resolves a later candidate only when the earlier ones miss), `Sha256SumsManifest.Parse` maps its lines with `Select`, and `UpdateInstaller.IsPreserved` filters with `Where`/`Any` behind a named `MatchesTopLevelSegment` helper. +- **Test consoles are disposed.** Ten `TestConsole` instances that were test-locals now sit behind `using`. Three others are deliberately left undisposed and now say so in a comment: they escape their harness method via its return value, and the calling test reads `console.Output` after that method has returned, so disposing at the creation site closes the writer before the command under test writes to it. + +- **CodeQL now excludes the two P/Invoke audit queries at the config level** (NextIteration.Standards §4.4, §3.0.1). A `query-filters` block in `codeql.yml` excludes exactly `cs/unmanaged-code` and `cs/call-to-unmanaged-code`. This package contains no P/Invoke, so the block matches nothing here — it is carried because §3.0.1 requires the workflow to equal the canonical template in non-comment content, and a repo that omits it has forked the template rather than customised it. Closes the `3.0.1 workflow content` audit row. + - **`IDE0005` suppressed in the non-shipping projects.** It only runs in-build when `GenerateDocumentationFile` is `true`, which both the test and demo projects set to `false` — sample and fixture code carries no XML docs, and `TreatWarningsAsErrors` would fail the build over every missing one. With `EnforceCodeStyleInBuild` on it hard-errors demanding the doc file be enabled instead. NextIteration.Standards §2.7 mandates this for the test project; the demo is the same shape (not shipped, so not public surface) and needs the same opt-out. `IDE0005` still gates the shipping project, where the doc file is on — and it caught five real unnecessary usings there. - **`release.yml` folded into `ci.yml`.** Publishing now happens in the same workflow run as the build, so the `publish` job pushes the artifact this run's `build` job produced — the exact bytes the gate tested. The old tag-triggered `release.yml` rebuilt from the tag and published an artifact no gate had ever seen. It also globbed `*.nupkg` when uploading, so the `.snupkg` was built and then silently never published; the glob is now `*nupkg` and symbols ship. Repointing the nuget.org Trusted Publishing policy from `release.yml` to `ci.yml` was part of the same change, because the policy is bound to a workflow filename. diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs index 7491f0c..01e528f 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs @@ -145,7 +145,7 @@ internal string ResolveCacheFilePath() var dir = !string.IsNullOrWhiteSpace(_options.CacheDirectory) ? _options.CacheDirectory! : DefaultCacheDirectory(_options.AppName); - return Path.Combine(dir, ".update-check.json"); + return Path.Join(dir, ".update-check.json"); } internal bool IsCacheFresh(UpdateCacheEntry? entry) => @@ -322,7 +322,7 @@ internal static string DefaultCacheDirectory(string appName) else if (OperatingSystem.IsMacOS()) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - root = Path.Combine(home, "Library", "Caches"); + root = Path.Join(home, "Library", "Caches"); } else { @@ -330,11 +330,11 @@ internal static string DefaultCacheDirectory(string appName) if (string.IsNullOrWhiteSpace(xdg)) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - xdg = Path.Combine(home, ".cache"); + xdg = Path.Join(home, ".cache"); } root = xdg; } - return Path.Combine(root, appName); + return Path.Join(root, appName); } // ---------- Helpers ---------- diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index ab6fc51..2e0e78c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -61,8 +61,8 @@ public bool HasPendingCleanup get { var installDir = InstallDirectory; - return Directory.Exists(Path.Combine(installDir, OldDirName)) - || Directory.Exists(Path.Combine(installDir, StagingDirName)); + return Directory.Exists(Path.Join(installDir, OldDirName)) + || Directory.Exists(Path.Join(installDir, StagingDirName)); } } @@ -84,9 +84,9 @@ public async Task InstallAsync( ValidateAssetName(asset.Name); var installDir = InstallDirectory; - var stagingDir = Path.Combine(installDir, StagingDirName, SanitizeTag(release.Tag)); - var oldDir = Path.Combine(installDir, OldDirName); - var lockFile = Path.Combine(installDir, LockFileName); + var stagingDir = Path.Join(installDir, StagingDirName, SanitizeTag(release.Tag)); + var oldDir = Path.Join(installDir, OldDirName); + var lockFile = Path.Join(installDir, LockFileName); // Acquire the lock BEFORE touching the staging tree. Otherwise a // second installer can wipe a first installer's in-flight staging @@ -99,7 +99,7 @@ public async Task InstallAsync( try { - var archivePath = Path.Combine(stagingDir, asset.Name); + var archivePath = Path.Join(stagingDir, asset.Name); using (var dlCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) { @@ -117,7 +117,7 @@ public async Task InstallAsync( progress?.Report(new UpdateProgressEvent(UpdateStage.Verifying, 1)); progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 0)); - var extractedDir = Path.Combine(stagingDir, "extracted"); + var extractedDir = Path.Join(stagingDir, "extracted"); await ArchiveExtractor.ExtractAsync(archivePath, extractedDir, ct).ConfigureAwait(false); var sourceDir = ResolveSourceDirectory(extractedDir); progress?.Report(new UpdateProgressEvent(UpdateStage.Extracting, 1)); @@ -130,7 +130,7 @@ public async Task InstallAsync( { // Drop the whole .update/ tree, not just the leaf /, so // we don't leave an empty parent behind on the install dir. - TryDeleteDirectory(Path.Combine(installDir, StagingDirName)); + TryDeleteDirectory(Path.Join(installDir, StagingDirName)); } } @@ -143,8 +143,8 @@ public void CleanupOldInstall() // for the OneDrive / antivirus / Windows Search case where // handles held at install time defeated the immediate cleanup; // by next startup those handles have had time to release. - TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, OldDirName))); - TrySwallow(() => DeleteDirectoryRobustly(Path.Combine(installDir, StagingDirName))); + TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, OldDirName))); + TrySwallow(() => DeleteDirectoryRobustly(Path.Join(installDir, StagingDirName))); } private static void TrySwallow(Action action) @@ -232,7 +232,7 @@ internal static async Task SwapAsync( continue; } - var dest = Path.Combine(oldDirectory, name); + var dest = Path.Join(oldDirectory, name); if (File.Exists(entry)) { File.Move(entry, dest); @@ -262,7 +262,7 @@ internal static async Task SwapAsync( { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(entry); - var dest = Path.Combine(installDirectory, name); + var dest = Path.Join(installDirectory, name); // Conflict path: source ships an entry whose top-level // name matches a preserved pattern. @@ -281,7 +281,7 @@ internal static async Task SwapAsync( // moved in Phase 1; do it inline now so the // copy below has somewhere clean to land and // rollback restores the right thing on failure. - var oldDest = Path.Combine(oldDirectory, name); + var oldDest = Path.Join(oldDirectory, name); TryDeleteEntry(oldDest); if (File.Exists(dest)) { @@ -314,7 +314,7 @@ internal static async Task SwapAsync( { foreach (var name in copiedNames) { - TryDeleteEntry(Path.Combine(installDirectory, name)); + TryDeleteEntry(Path.Join(installDirectory, name)); } RestoreFromOld(oldDirectory, installDirectory, movedNames); throw; @@ -346,29 +346,20 @@ internal static bool IsPreserved(string name, IReadOnlyList preservePath return false; } - foreach (var pattern in preservePaths) - { - if (string.IsNullOrWhiteSpace(pattern)) - { - continue; - } - // Take the part of the pattern before the first slash — - // this lets `data/**`, `data/seed.json`, and bare `data` - // all match the top-level entry `data`. Nested-only - // preservation (e.g. preserve only `data/seed.json` but - // not the rest of `data/`) is out of scope for v0.1.x. - var head = TopLevelSegment(pattern); - if (head.Length == 0) - { - continue; - } + return preservePaths + .Where(pattern => !string.IsNullOrWhiteSpace(pattern)) + .Any(pattern => MatchesTopLevelSegment(pattern, name)); + } - if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true)) - { - return true; - } - } - return false; + // Take the part of the pattern before the first slash — this lets + // `data/**`, `data/seed.json`, and bare `data` all match the top-level + // entry `data`. Nested-only preservation (e.g. preserve only + // `data/seed.json` but not the rest of `data/`) is out of scope. + private static bool MatchesTopLevelSegment(string pattern, string name) + { + var head = TopLevelSegment(pattern); + return head.Length != 0 + && System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true); } private static ReadOnlySpan TopLevelSegment(string pattern) @@ -393,8 +384,8 @@ internal static void RestoreFromOld(string oldDirectory, string installDirectory { foreach (var name in names) { - var src = Path.Combine(oldDirectory, name); - var dest = Path.Combine(installDirectory, name); + var src = Path.Join(oldDirectory, name); + var dest = Path.Join(installDirectory, name); try { TryDeleteEntry(dest); @@ -516,7 +507,7 @@ internal static void CopyDirectory(string source, string destination) foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(source, file); - var target = Path.Combine(destination, relative); + var target = Path.Join(destination, relative); Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Copy(file, target, overwrite: true); } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs index ca7eb5c..e9900a4 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs @@ -43,15 +43,11 @@ public DefaultAssetResolver(string appName) ArgumentNullException.ThrowIfNull(release); ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier); - foreach (var rid in CandidateRids(runtimeIdentifier)) - { - var match = ResolveForRid(release, rid); - if (match is not null) - { - return match; - } - } - return null; + // Select is lazy and FirstOrDefault short-circuits, so a later + // candidate RID is only resolved when the earlier ones miss. + return CandidateRids(runtimeIdentifier) + .Select(rid => ResolveForRid(release, rid)) + .FirstOrDefault(match => match is not null); } private ReleaseAsset? ResolveForRid(RemoteRelease release, string rid) @@ -108,60 +104,27 @@ private static IEnumerable CandidateRids(string runtimeIdentifier) private static ReleaseAsset? MatchExact(RemoteRelease release, string stem) { - foreach (var asset in release.Assets) - { - foreach (var ext in ArchiveExtensions) - { - if (NameEquals(asset.Name, stem + ext)) - { - return asset; - } - } - } - return null; + return release.Assets.FirstOrDefault( + asset => ArchiveExtensions.Any(ext => NameEquals(asset.Name, stem + ext))); } private static ReleaseAsset? MatchPrefixSuffix(RemoteRelease release, string prefix, string ridSuffix) { - foreach (var asset in release.Assets) - { - if (!asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!EndsWithRidAndArchive(asset.Name, ridSuffix)) - { - continue; - } - - return asset; - } - return null; + return release.Assets.FirstOrDefault( + asset => asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && EndsWithRidAndArchive(asset.Name, ridSuffix)); } private static ReleaseAsset? MatchSuffixOnly(RemoteRelease release, string ridSuffix) { - foreach (var asset in release.Assets) - { - if (EndsWithRidAndArchive(asset.Name, ridSuffix)) - { - return asset; - } - } - return null; + return release.Assets.FirstOrDefault( + asset => EndsWithRidAndArchive(asset.Name, ridSuffix)); } private static bool EndsWithRidAndArchive(string name, string ridSuffix) { - foreach (var ext in ArchiveExtensions) - { - if (name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; + return ArchiveExtensions.Any( + ext => name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)); } private static bool NameEquals(string actual, string expected) => diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs index bd12edd..ff2d7f3 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs @@ -159,7 +159,7 @@ public async Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IPr $"GhCliReleaseSource cannot download '{asset.Name}': release tag is not present in asset metadata."); } - var tempFile = Path.Combine(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}"); + var tempFile = Path.Join(Path.GetTempPath(), $"selfupdate-gh-{Guid.NewGuid():N}-{asset.Name}"); try { progress?.Report(new DownloadProgress(0, asset.SizeBytes)); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs index c691da6..28d0c7b 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs @@ -16,9 +16,8 @@ public static IReadOnlyDictionary Parse(string content) ArgumentNullException.ThrowIfNull(content); var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var rawLine in content.Split('\n')) + foreach (var line in content.Split('\n').Select(rawLine => rawLine.Trim())) { - var line = rawLine.Trim(); if (line.Length == 0 || line.StartsWith('#')) { continue; diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs index 9afddfa..b9b3d66 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs @@ -86,6 +86,9 @@ public void AddUpdateBranch_rejects_blank_name() private static (CommandApp App, TestConsole Console) BuildApp(Action configure) { + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs index 72793d4..48a45ca 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCheckCommandTests.cs @@ -127,6 +127,9 @@ private static (Runner Run, TestConsole Console, StubUpdateChecker Checker) Buil { var checker = new StubUpdateChecker(); configChecker(checker); + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index 458bbd4..714c1a9 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -257,6 +257,9 @@ private static (Runner Run, TestConsole Console, StubSelfUpdater Updater) BuildH configUpdater?.Invoke(updater); var installer = new StubUpdateInstaller(); configInstaller?.Invoke(installer); + // Deliberately not `using`: this console escapes via the return + // value and its lifetime belongs to the caller, which reads + // console.Output after the harness method has returned. var console = new TestConsole(); var registrar = new TestRegistrar(s => diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs index ada1106..aeceade 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs @@ -15,11 +15,11 @@ internal sealed class TempDir : IDisposable public TempDir(string? prefix = null) { var name = (prefix ?? "selfupdate") + "-" + Guid.NewGuid().ToString("N"); - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), name); + Path = System.IO.Path.Join(System.IO.Path.GetTempPath(), name); Directory.CreateDirectory(Path); } - public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); + public string Combine(params string[] parts) => System.IO.Path.Join([Path, .. parts]); public void Dispose() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs index 3994ce0..123ada1 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/ArchiveExtractorTests.cs @@ -20,8 +20,8 @@ public async Task ExtractAsync_extracts_zip_archive() await ArchiveExtractor.ExtractAsync(zipPath, dest, CancellationToken.None); - Assert.Equal("hello", await File.ReadAllTextAsync(Path.Combine(dest, "file1.txt"), TestContext.Current.CancellationToken)); - Assert.Equal("world", await File.ReadAllTextAsync(Path.Combine(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("hello", await File.ReadAllTextAsync(Path.Join(dest, "file1.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("world", await File.ReadAllTextAsync(Path.Join(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); } [Fact] @@ -34,8 +34,8 @@ public async Task ExtractAsync_extracts_tar_gz_archive() await ArchiveExtractor.ExtractAsync(tarGzPath, dest, CancellationToken.None); - Assert.Equal("hello", await File.ReadAllTextAsync(Path.Combine(dest, "file1.txt"), TestContext.Current.CancellationToken)); - Assert.Equal("world", await File.ReadAllTextAsync(Path.Combine(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("hello", await File.ReadAllTextAsync(Path.Join(dest, "file1.txt"), TestContext.Current.CancellationToken)); + Assert.Equal("world", await File.ReadAllTextAsync(Path.Join(dest, "nested", "file2.txt"), TestContext.Current.CancellationToken)); } [Fact] @@ -68,7 +68,7 @@ private static void CreateTarGz(string path, params (string Name, string Content using var staging = new TempDir("tar-stage"); foreach (var (name, content) in entries) { - var full = Path.Combine(staging.Path, name); + var full = Path.Join(staging.Path, name); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, content); } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs index 8587031..068d7df 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/InstallLockTests.cs @@ -11,7 +11,7 @@ public sealed class InstallLockTests public void Acquire_on_fresh_path_returns_open_stream() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); using var stream = InstallLock.Acquire(lockPath, dir.Path); @@ -23,7 +23,7 @@ public void Acquire_on_fresh_path_returns_open_stream() public void Acquire_when_lock_already_held_throws_in_progress() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); using var first = InstallLock.Acquire(lockPath, dir.Path); @@ -35,9 +35,9 @@ public void Acquire_when_lock_already_held_throws_in_progress() public void Disposing_first_lock_removes_file_and_allows_re_acquisition() { using var dir = new TempDir(); - var lockPath = Path.Combine(dir.Path, ".update.lock"); + var lockPath = Path.Join(dir.Path, ".update.lock"); - using (var first = InstallLock.Acquire(lockPath, dir.Path)) + using (InstallLock.Acquire(lockPath, dir.Path)) { Assert.True(File.Exists(lockPath)); } @@ -71,14 +71,14 @@ public void Acquire_when_directory_not_writable_throws_not_writable() } using var dir = new TempDir(); - var sub = Path.Combine(dir.Path, "ro"); + var sub = Path.Join(dir.Path, "ro"); Directory.CreateDirectory(sub); try { File.SetUnixFileMode(sub, UnixFileMode.UserRead | UnixFileMode.UserExecute); var ex = Assert.Throws(() => - InstallLock.Acquire(Path.Combine(sub, ".update.lock"), sub)); + InstallLock.Acquire(Path.Join(sub, ".update.lock"), sub)); Assert.Contains("not writable", ex.Message, StringComparison.OrdinalIgnoreCase); } finally diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs index 7f476b4..cac2a64 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Pipeline/UpdateInstallerTests.cs @@ -21,9 +21,9 @@ public sealed class UpdateInstallerTests public async Task InstallAsync_swaps_files_into_install_directory() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); - File.WriteAllText(Path.Combine(installDir, "old-file.txt"), "old content"); + File.WriteAllText(Path.Join(installDir, "old-file.txt"), "old content"); var release = BuildReleaseWithSingleAssetZip( tag: "v1.4.2", @@ -43,22 +43,22 @@ public async Task InstallAsync_swaps_files_into_install_directory() await installer.InstallAsync(release, progress: null, onConflict: null, CancellationToken.None); // New files in place - Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Combine(installDir, "myapp.exe"), TestContext.Current.CancellationToken)); - Assert.Equal("{}", await File.ReadAllTextAsync(Path.Combine(installDir, "settings.json"), TestContext.Current.CancellationToken)); + Assert.Equal("new binary", await File.ReadAllTextAsync(Path.Join(installDir, "myapp.exe"), TestContext.Current.CancellationToken)); + Assert.Equal("{}", await File.ReadAllTextAsync(Path.Join(installDir, "settings.json"), TestContext.Current.CancellationToken)); // Old file moved to .old/ - Assert.True(Directory.Exists(Path.Combine(installDir, ".old"))); - Assert.Equal("old content", await File.ReadAllTextAsync(Path.Combine(installDir, ".old", "old-file.txt"), TestContext.Current.CancellationToken)); + Assert.True(Directory.Exists(Path.Join(installDir, ".old"))); + Assert.Equal("old content", await File.ReadAllTextAsync(Path.Join(installDir, ".old", "old-file.txt"), TestContext.Current.CancellationToken)); // Staging removed - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public async Task InstallAsync_throws_when_no_asset_matches_rid() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = new RemoteRelease( @@ -83,7 +83,7 @@ public async Task InstallAsync_throws_when_no_asset_matches_rid() public async Task InstallAsync_reports_progress_for_each_stage() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x"), ("b.txt", "y")); @@ -112,14 +112,14 @@ public async Task InstallAsync_reports_progress_for_each_stage() public void CleanupOldInstall_when_old_directory_exists_deletes_it() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".old", "garbage.txt"), "stale"); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".old", "garbage.txt"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".old"))); } [Fact] @@ -131,39 +131,39 @@ public void CleanupOldInstall_when_update_staging_exists_deletes_it() // this CleanupOldInstall would only touch .old/ and .update/ // would accumulate across sessions. using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - var staging = Path.Combine(installDir, ".update", "v1.4.2"); + var installDir = Path.Join(work.Path, "install"); + var staging = Path.Join(installDir, ".update", "v1.4.2"); Directory.CreateDirectory(staging); - File.WriteAllText(Path.Combine(staging, "leftover.txt"), "stale"); + File.WriteAllText(Path.Join(staging, "leftover.txt"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public void CleanupOldInstall_cleans_both_old_and_update_directories() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".old", "garbage.txt"), "stale"); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); - File.WriteAllText(Path.Combine(installDir, ".update", "v1.4.2", "archive.zip"), "stale"); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".old", "garbage.txt"), "stale"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1.4.2")); + File.WriteAllText(Path.Join(installDir, ".update", "v1.4.2", "archive.zip"), "stale"); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); installer.CleanupOldInstall(); - Assert.False(Directory.Exists(Path.Combine(installDir, ".old"))); - Assert.False(Directory.Exists(Path.Combine(installDir, ".update"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".old"))); + Assert.False(Directory.Exists(Path.Join(installDir, ".update"))); } [Fact] public void CleanupOldInstall_when_old_directory_missing_is_noop() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -174,7 +174,7 @@ public void CleanupOldInstall_when_old_directory_missing_is_noop() public void HasPendingCleanup_when_neither_directory_exists_false() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -186,8 +186,8 @@ public void HasPendingCleanup_when_neither_directory_exists_false() public void HasPendingCleanup_when_old_directory_exists_true() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".old")); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -198,8 +198,8 @@ public void HasPendingCleanup_when_old_directory_exists_true() public void HasPendingCleanup_when_update_directory_exists_true() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1.4.2")); + var installDir = Path.Join(work.Path, "install"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1.4.2")); var installer = NewInstaller(installDir, new FakeUpdateSource(), "linux-x64"); @@ -210,7 +210,7 @@ public void HasPendingCleanup_when_update_directory_exists_true() public async Task InstallAsync_when_lock_file_held_throws() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x")); @@ -220,7 +220,7 @@ public async Task InstallAsync_when_lock_file_held_throws() }; // Hold the lock externally. - var lockPath = Path.Combine(installDir, ".update.lock"); + var lockPath = Path.Join(installDir, ".update.lock"); using var foreignLock = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var installer = NewInstaller(installDir, source, "linux-x64"); @@ -235,17 +235,17 @@ public async Task InstallAsync_when_lock_held_leaves_existing_staging_intact() // the lock, so a second installer would wipe a first installer's // in-flight staging directory on its way to losing the lock race. using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); // Pre-populate a staging dir as if another installer were mid-download. - var stagingDir = Path.Combine(installDir, ".update", "v1.0.0"); + var stagingDir = Path.Join(installDir, ".update", "v1.0.0"); Directory.CreateDirectory(stagingDir); - var sentinel = Path.Combine(stagingDir, "in-flight-asset.zip"); + var sentinel = Path.Join(stagingDir, "in-flight-asset.zip"); File.WriteAllBytes(sentinel, [1, 2, 3]); // Hold the lock externally to simulate that other installer. - var lockPath = Path.Combine(installDir, ".update.lock"); + var lockPath = Path.Join(installDir, ".update.lock"); using var foreignLock = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var release = BuildReleaseWithSingleAssetZip("v1.0.0", "myapp-v1.0.0-linux-x64.zip", ("a.txt", "x")); @@ -266,7 +266,7 @@ await Assert.ThrowsAsync(() => public async Task InstallAsync_when_resolver_returns_malicious_name_throws() { using var work = new TempDir(); - var installDir = Path.Combine(work.Path, "install"); + var installDir = Path.Join(work.Path, "install"); Directory.CreateDirectory(installDir); var malicious = new ReleaseAsset( @@ -345,16 +345,16 @@ public void RestoreFromOld_moves_named_entries_back_into_install() Directory.CreateDirectory(installDir); Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(oldDir, "a.txt"), "alpha"); - Directory.CreateDirectory(Path.Combine(oldDir, "subdir")); - File.WriteAllText(Path.Combine(oldDir, "subdir", "nested.txt"), "nested"); + File.WriteAllText(Path.Join(oldDir, "a.txt"), "alpha"); + Directory.CreateDirectory(Path.Join(oldDir, "subdir")); + File.WriteAllText(Path.Join(oldDir, "subdir", "nested.txt"), "nested"); UpdateInstaller.RestoreFromOld(oldDir, installDir, TwoEntryNames); - Assert.Equal("alpha", File.ReadAllText(Path.Combine(installDir, "a.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, "subdir"))); - Assert.Equal("nested", File.ReadAllText(Path.Combine(installDir, "subdir", "nested.txt"))); - Assert.False(File.Exists(Path.Combine(oldDir, "a.txt"))); + Assert.Equal("alpha", File.ReadAllText(Path.Join(installDir, "a.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, "subdir"))); + Assert.Equal("nested", File.ReadAllText(Path.Join(installDir, "subdir", "nested.txt"))); + Assert.False(File.Exists(Path.Join(oldDir, "a.txt"))); } [Fact] @@ -366,12 +366,12 @@ public void RestoreFromOld_overwrites_existing_destination_entries() Directory.CreateDirectory(installDir); Directory.CreateDirectory(oldDir); - File.WriteAllText(Path.Combine(installDir, "a.txt"), "garbage from a partial copy"); - File.WriteAllText(Path.Combine(oldDir, "a.txt"), "original"); + File.WriteAllText(Path.Join(installDir, "a.txt"), "garbage from a partial copy"); + File.WriteAllText(Path.Join(oldDir, "a.txt"), "original"); UpdateInstaller.RestoreFromOld(oldDir, installDir, OneEntryName); - Assert.Equal("original", File.ReadAllText(Path.Combine(installDir, "a.txt"))); + Assert.Equal("original", File.ReadAllText(Path.Join(installDir, "a.txt"))); } [Fact] @@ -386,27 +386,27 @@ public void Swap_when_phase2_copy_fails_restores_install_from_old() using var work = new TempDir(); var installDir = work.Combine("install"); Directory.CreateDirectory(installDir); - File.WriteAllText(Path.Combine(installDir, "current.txt"), "original"); - Directory.CreateDirectory(Path.Combine(installDir, "vendor")); - File.WriteAllText(Path.Combine(installDir, "vendor", "lib.txt"), "lib v1"); + File.WriteAllText(Path.Join(installDir, "current.txt"), "original"); + Directory.CreateDirectory(Path.Join(installDir, "vendor")); + File.WriteAllText(Path.Join(installDir, "vendor", "lib.txt"), "lib v1"); var sourceDir = work.Combine("src"); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "newfile.txt"), "new content"); - File.WriteAllText(Path.Combine(sourceDir, ".old"), "should clash with the .old/ dir"); + File.WriteAllText(Path.Join(sourceDir, "newfile.txt"), "new content"); + File.WriteAllText(Path.Join(sourceDir, ".old"), "should clash with the .old/ dir"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Assert.ThrowsAny(() => UpdateInstaller.Swap(sourceDir, installDir, oldDir)); // Original install state restored. - Assert.True(File.Exists(Path.Combine(installDir, "current.txt"))); - Assert.Equal("original", File.ReadAllText(Path.Combine(installDir, "current.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, "vendor"))); - Assert.Equal("lib v1", File.ReadAllText(Path.Combine(installDir, "vendor", "lib.txt"))); + Assert.True(File.Exists(Path.Join(installDir, "current.txt"))); + Assert.Equal("original", File.ReadAllText(Path.Join(installDir, "current.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, "vendor"))); + Assert.Equal("lib v1", File.ReadAllText(Path.Join(installDir, "vendor", "lib.txt"))); // Anything Phase 2 managed to place before the failure is gone. - Assert.False(File.Exists(Path.Combine(installDir, "newfile.txt"))); + Assert.False(File.Exists(Path.Join(installDir, "newfile.txt"))); } [Theory] @@ -434,12 +434,12 @@ public async Task SwapAsync_preserved_entry_is_not_moved_to_old() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.Development.json"), "user-config"); - File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); - File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + File.WriteAllText(Path.Join(installDir, "appsettings.Development.json"), "user-config"); + File.WriteAllText(Path.Join(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Join(sourceDir, "binary.exe"), "new-binary"); await UpdateInstaller.SwapAsync( sourceDir, installDir, oldDir, @@ -448,11 +448,11 @@ await UpdateInstaller.SwapAsync( CancellationToken.None); // Preserved file untouched. - Assert.Equal("user-config", File.ReadAllText(Path.Combine(installDir, "appsettings.Development.json"))); - Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.Development.json"))); + Assert.Equal("user-config", File.ReadAllText(Path.Join(installDir, "appsettings.Development.json"))); + Assert.False(File.Exists(Path.Join(oldDir, "appsettings.Development.json"))); // Non-preserved file replaced. - Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); - Assert.Equal("old-binary", File.ReadAllText(Path.Combine(oldDir, "binary.exe"))); + Assert.Equal("new-binary", File.ReadAllText(Path.Join(installDir, "binary.exe"))); + Assert.Equal("old-binary", File.ReadAllText(Path.Join(oldDir, "binary.exe"))); } [Fact] @@ -461,11 +461,11 @@ public async Task SwapAsync_when_release_conflicts_default_keeps_existing() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + File.WriteAllText(Path.Join(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.json"), "release-default"); await UpdateInstaller.SwapAsync( sourceDir, installDir, oldDir, @@ -473,8 +473,8 @@ await UpdateInstaller.SwapAsync( onConflict: null, // null → keep existing CancellationToken.None); - Assert.Equal("user-edited", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); - Assert.False(File.Exists(Path.Combine(oldDir, "appsettings.json"))); + Assert.Equal("user-edited", File.ReadAllText(Path.Join(installDir, "appsettings.json"))); + Assert.False(File.Exists(Path.Join(oldDir, "appsettings.json"))); } [Fact] @@ -483,11 +483,11 @@ public async Task SwapAsync_when_resolver_returns_use_new_replaces_existing() using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "appsettings.json"), "user-edited"); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.json"), "release-default"); + File.WriteAllText(Path.Join(installDir, "appsettings.json"), "user-edited"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.json"), "release-default"); UpdateConflict? sawConflict = null; Func> resolver = @@ -501,9 +501,9 @@ await UpdateInstaller.SwapAsync( Assert.NotNull(sawConflict); Assert.Equal("appsettings.json", sawConflict!.RelativePath); - Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.json"))); + Assert.Equal("release-default", File.ReadAllText(Path.Join(installDir, "appsettings.json"))); // Previous user copy moved to .old/ (so the next-startup cleanup sweep removes it). - Assert.Equal("user-edited", File.ReadAllText(Path.Combine(oldDir, "appsettings.json"))); + Assert.Equal("user-edited", File.ReadAllText(Path.Join(oldDir, "appsettings.json"))); } [Fact] @@ -512,12 +512,12 @@ public async Task SwapAsync_preserved_glob_does_not_block_unrelated_release_file using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(installDir, "myapp.db"), "user-db"); - File.WriteAllText(Path.Combine(installDir, "binary.exe"), "old-binary"); - File.WriteAllText(Path.Combine(sourceDir, "binary.exe"), "new-binary"); + File.WriteAllText(Path.Join(installDir, "myapp.db"), "user-db"); + File.WriteAllText(Path.Join(installDir, "binary.exe"), "old-binary"); + File.WriteAllText(Path.Join(sourceDir, "binary.exe"), "new-binary"); // Note: source does NOT ship myapp.db. await UpdateInstaller.SwapAsync( @@ -526,8 +526,8 @@ await UpdateInstaller.SwapAsync( onConflict: null, CancellationToken.None); - Assert.Equal("user-db", File.ReadAllText(Path.Combine(installDir, "myapp.db"))); - Assert.Equal("new-binary", File.ReadAllText(Path.Combine(installDir, "binary.exe"))); + Assert.Equal("user-db", File.ReadAllText(Path.Join(installDir, "myapp.db"))); + Assert.Equal("new-binary", File.ReadAllText(Path.Join(installDir, "binary.exe"))); } [Fact] @@ -536,10 +536,10 @@ public async Task SwapAsync_preserved_path_introduced_by_new_release_when_user_h using var work = new TempDir(); var installDir = work.Combine("install"); var sourceDir = work.Combine("src"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); Directory.CreateDirectory(installDir); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "appsettings.Production.json"), "release-default"); + File.WriteAllText(Path.Join(sourceDir, "appsettings.Production.json"), "release-default"); // The path is preservable but the user doesn't have a copy yet — // installer should just place the new file with no resolver call. @@ -554,7 +554,7 @@ await UpdateInstaller.SwapAsync( CancellationToken.None); Assert.False(resolverCalled); - Assert.Equal("release-default", File.ReadAllText(Path.Combine(installDir, "appsettings.Production.json"))); + Assert.Equal("release-default", File.ReadAllText(Path.Join(installDir, "appsettings.Production.json"))); } [Fact] @@ -563,21 +563,21 @@ public void Swap_preserves_maintenance_entries() using var work = new TempDir(); var installDir = work.Combine("install"); Directory.CreateDirectory(installDir); - Directory.CreateDirectory(Path.Combine(installDir, ".update", "v1")); - Directory.CreateDirectory(Path.Combine(installDir, ".old")); - File.WriteAllText(Path.Combine(installDir, ".update.lock"), ""); - File.WriteAllText(Path.Combine(installDir, "real-file.txt"), "live"); + Directory.CreateDirectory(Path.Join(installDir, ".update", "v1")); + Directory.CreateDirectory(Path.Join(installDir, ".old")); + File.WriteAllText(Path.Join(installDir, ".update.lock"), ""); + File.WriteAllText(Path.Join(installDir, "real-file.txt"), "live"); var sourceDir = work.Combine("src"); Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "real-file.txt"), "updated"); + File.WriteAllText(Path.Join(sourceDir, "real-file.txt"), "updated"); - var oldDir = Path.Combine(installDir, ".old"); + var oldDir = Path.Join(installDir, ".old"); UpdateInstaller.Swap(sourceDir, installDir, oldDir); - Assert.Equal("updated", File.ReadAllText(Path.Combine(installDir, "real-file.txt"))); - Assert.True(Directory.Exists(Path.Combine(installDir, ".update", "v1")), ".update should not be moved into .old"); - Assert.True(File.Exists(Path.Combine(installDir, ".update.lock")), "lock file should survive the swap"); + Assert.Equal("updated", File.ReadAllText(Path.Join(installDir, "real-file.txt"))); + Assert.True(Directory.Exists(Path.Join(installDir, ".update", "v1")), ".update should not be moved into .old"); + Assert.True(File.Exists(Path.Join(installDir, ".update.lock")), "lock file should survive the swap"); } // ---------- Helpers ---------- @@ -621,7 +621,7 @@ private static RemoteRelease BuildReleaseWithSingleAssetZip( public void DeleteDirectoryRobustly_when_path_missing_is_noop() { using var work = new TempDir(); - var missing = Path.Combine(work.Path, "does-not-exist"); + var missing = Path.Join(work.Path, "does-not-exist"); var deleterCalls = 0; var sleeperCalls = 0; @@ -638,7 +638,7 @@ public void DeleteDirectoryRobustly_when_path_missing_is_noop() public void DeleteDirectoryRobustly_succeeds_first_try_calls_deleter_once_no_sleeps() { using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -663,7 +663,7 @@ public void DeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure // Simulates OneDrive / antivirus / Windows Search holding a // sharing-violation handle that releases within a few hundred ms. using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -690,7 +690,7 @@ public void DeleteDirectoryRobustly_retries_with_backoff_on_transient_io_failure public void DeleteDirectoryRobustly_throws_after_exhausting_retries() { using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); var deleterCalls = 0; @@ -721,9 +721,9 @@ public void DeleteDirectoryRobustly_clears_readonly_attribute_on_descendant_file // delegating, otherwise extracted-archive content (which often // arrives ReadOnly on Windows) breaks the recursive delete. using var work = new TempDir(); - var dir = Path.Combine(work.Path, "tree"); + var dir = Path.Join(work.Path, "tree"); Directory.CreateDirectory(dir); - var readOnlyFile = Path.Combine(dir, "readonly.txt"); + var readOnlyFile = Path.Join(dir, "readonly.txt"); File.WriteAllText(readOnlyFile, "ro"); File.SetAttributes(readOnlyFile, File.GetAttributes(readOnlyFile) | FileAttributes.ReadOnly); diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs index f1d9fee..5955017 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateBannerTests.cs @@ -30,7 +30,7 @@ public void RenderIfAvailable_when_update_present_renders_banner() { var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: new Uri("https://example.com/r")); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -45,7 +45,7 @@ public void RenderIfAvailable_when_no_release_url_omits_link() { var info = new UpdateInfo("1.0.0", "v1.4.2", IsUpdateAvailable: true, ReleaseUrl: null); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -57,7 +57,7 @@ public void RenderIfAvailable_when_no_release_url_omits_link() public void RenderIfAvailable_when_info_is_null_writes_nothing() { var task = Task.FromResult(null); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -69,7 +69,7 @@ public void RenderIfAvailable_when_up_to_date_writes_nothing() { var info = new UpdateInfo("1.4.2", "v1.4.2", IsUpdateAvailable: false, ReleaseUrl: null); var task = Task.FromResult(info); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); @@ -80,7 +80,7 @@ public void RenderIfAvailable_when_up_to_date_writes_nothing() public void RenderIfAvailable_when_task_does_not_complete_in_time_writes_nothing() { var tcs = new TaskCompletionSource(); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(tcs.Task, waitFor: TimeSpan.FromMilliseconds(50), console: console); @@ -95,7 +95,7 @@ public void RenderIfAvailable_when_task_does_not_complete_in_time_writes_nothing public void RenderIfAvailable_when_task_faulted_swallows_and_writes_nothing() { var task = Task.FromException(new InvalidOperationException("boom")); - var console = new TestConsole(); + using var console = new TestConsole(); UpdateBanner.RenderIfAvailable(task, waitFor: TimeSpan.FromSeconds(1), console: console); diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs index 6380459..7326f9e 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/UpdateCleanupTests.cs @@ -15,7 +15,7 @@ public sealed class UpdateCleanupTests public void Run_when_no_pending_cleanup_writes_nothing_and_cleans() { var installer = new StubUpdateInstaller { HasPendingCleanup = false }; - var console = new TestConsole(); + using var console = new TestConsole(); UpdateCleanup.Run(installer, console); @@ -27,7 +27,7 @@ public void Run_when_no_pending_cleanup_writes_nothing_and_cleans() public void Run_when_pending_cleanup_renders_message_and_cleans() { var installer = new StubUpdateInstaller { HasPendingCleanup = true }; - var console = new TestConsole(); + using var console = new TestConsole(); UpdateCleanup.Run(installer, console); @@ -39,7 +39,7 @@ public void Run_when_pending_cleanup_renders_message_and_cleans() public void Run_via_service_provider_resolves_installer_and_console() { var installer = new StubUpdateInstaller { HasPendingCleanup = true }; - var console = new TestConsole(); + using var console = new TestConsole(); var services = new ServiceCollection(); services.AddSingleton(installer); services.AddSingleton(console); @@ -55,7 +55,11 @@ public void Run_via_service_provider_resolves_installer_and_console() public void Run_with_null_services_throws() => Assert.Throws(() => UpdateCleanup.Run((IServiceProvider)null!)); [Fact] - public void Run_with_null_installer_throws() => Assert.Throws(() => UpdateCleanup.Run((IUpdateInstaller)null!, new TestConsole())); + public void Run_with_null_installer_throws() + { + using var console = new TestConsole(); + Assert.Throws(() => UpdateCleanup.Run((IUpdateInstaller)null!, console)); + } [Fact] public void Run_with_null_console_throws() => Assert.Throws(() => UpdateCleanup.Run(new StubUpdateInstaller(), null!));