Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -215,5 +215,5 @@ dotnet_diagnostic.CA1707.severity = none

# Tests: Method_Scenario_Expected naming and public [TestClass] types are the
# established convention.
[HostsFileEditor.Core.Tests/*.cs]
[HostsFileEditor.{Core,WinForm}.Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none
102 changes: 102 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: CI

# Gates every pull request (and pushes to master) on a clean build and a green test run on BOTH
# target architectures — x64 and ARM64 — since the app ships for both.
on:
pull_request:
push:
branches: [master]

# Cancel superseded runs on the same ref so a force-push doesn't pile up queued jobs.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
build-test:
name: build & test (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: x64
os: windows-latest
rid: win-x64
- arch: arm64
os: windows-11-arm
rid: win-arm64
runs-on: ${{ matrix.os }}

steps:
- name: Checkout
uses: actions/checkout@v4

# Installs the exact SDK pinned in global.json (10.0.301, rollForward latestFeature).
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

# Build the shared core, the headless CLI launcher, and the elevation helper. These are
# architecture-portable (a library / RID-agnostic builds), so no RID is needed here.
# TreatWarningsAsErrors is on repo-wide, so this also fails on any new analyzer warning.
- name: Build core, CLI and helper
run: |
dotnet build HostsFileEditor.Core/HostsFileEditor.Core.csproj -c Release
dotnet build HostsFileEditor.Cli/HostsFileEditor.Cli.csproj -c Release
dotnet build HostsFileEditor.Elevate/HostsFileEditor.Elevate.csproj -c Release

# Build the classic (WinForms) edition for THIS architecture. Both app projects default their
# RuntimeIdentifier to win-x64, so the RID must be passed explicitly or the arm64 leg would
# silently build x64 and never validate arm64.
- name: Build classic UI (WinForms)
run: dotnet build HostsFileEditor.WinForm/HostsFileEditor.WinForm.csproj -c Release -p:RuntimeIdentifier=${{ matrix.rid }}

# Build the modern (WinUI 3) edition for this architecture. A plain build (not publish) skips
# the AOT/trim/MSIX steps, so it needs no C++ toolchain — just the Windows App SDK NuGet.
- name: Build modern UI (WinUI)
run: dotnet build HostsFileEditor.WinUI/HostsFileEditor.WinUI.csproj -c Release -p:Platform=${{ matrix.arch }} -p:RuntimeIdentifier=${{ matrix.rid }}

# Runs the full Core/CLI test suite with OpenCover coverage (configured in coverage.runsettings).
- name: Test Core/CLI with coverage
run: >
dotnet test HostsFileEditor.Core.Tests/HostsFileEditor.Core.Tests.csproj
-c Release
--logger "trx;LogFileName=core-test-results.trx"
--results-directory ${{ github.workspace }}/TestResults

# Classic-edition (WinForms) UI-logic tests. The project pins RID win-x64, so it runs on the x64
# leg only; the modern edition is covered by its build gate above and shared logic lives in Core.
- name: Test classic UI (x64 only)
if: matrix.arch == 'x64'
run: >
dotnet test HostsFileEditor.WinForm.Tests/HostsFileEditor.WinForm.Tests.csproj
-c Release
--logger "trx;LogFileName=winform-test-results.trx"
--results-directory ${{ github.workspace }}/TestResults

- name: Coverage summary
if: always()
continue-on-error: true
shell: pwsh
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool 2>$null
$report = Get-ChildItem -Path "${{ github.workspace }}/TestResults" -Recurse -Filter coverage.opencover.xml | Select-Object -First 1
if (-not $report) { Write-Host "No coverage report found."; exit 0 }
reportgenerator -reports:"$($report.FullName)" -targetdir:"${{ github.workspace }}/CoverageReport" -reporttypes:"TextSummary;MarkdownSummaryGithub"
$md = "${{ github.workspace }}/CoverageReport/SummaryGithub.md"
if (Test-Path $md) { Get-Content $md | Add-Content -Path $env:GITHUB_STEP_SUMMARY }
Get-Content "${{ github.workspace }}/CoverageReport/Summary.txt" | Write-Host

- name: Upload test results and coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.arch }}
path: |
${{ github.workspace }}/TestResults/**/*.trx
${{ github.workspace }}/TestResults/**/coverage.opencover.xml
if-no-files-found: warn
90 changes: 90 additions & 0 deletions HostsFileEditor.Core.Tests/CoreExceptionsAndResourcesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using HostsFileEditor.Elevation;
using HostsFileEditor.Properties;

namespace HostsFileEditor.Core.Tests;

[TestClass]
public sealed class CoreExceptionsAndResourcesTests
{
[TestMethod]
public void ElevationCancelledException_DefaultMessage()
{
var ex = new ElevationCancelledException();
ex.Message.ShouldContain("administrator permission");
}

[TestMethod]
public void ElevationCancelledException_CustomMessage()
{
var ex = new ElevationCancelledException("custom");
ex.Message.ShouldBe("custom");
}

[TestMethod]
public void ElevationCancelledException_MessageAndInner()
{
var inner = new InvalidOperationException("inner");
var ex = new ElevationCancelledException("outer", inner);
ex.Message.ShouldBe("outer");
ex.InnerException.ShouldBe(inner);
}

[TestMethod]
public void HostsFileConflictException_DefaultConstructs()
{
var ex = new HostsFileConflictException();
ex.ShouldNotBeNull();
}

[TestMethod]
public void HostsFileConflictException_CustomMessage()
{
var ex = new HostsFileConflictException("conflict");
ex.Message.ShouldBe("conflict");
}

[TestMethod]
public void HostsFileConflictException_MessageAndInner()
{
var inner = new IOException("io");
var ex = new HostsFileConflictException("outer", inner);
ex.Message.ShouldBe("outer");
ex.InnerException.ShouldBe(inner);
}

// Touch every strongly-typed resource accessor so the generated getters are exercised. The three
// messages the app actually depends on for its validation/UX carry real text; the rest are read
// for coverage without asserting a value (some are placeholder/empty in the .resx).
[TestMethod]
public void Resources_AllAccessorsExecute()
{
_ = Resources.ArchiveExists;
_ = Resources.ErrorCaption;
_ = Resources.InputArchivePrompt;
_ = Resources.LoseChangesDialogCaption;
_ = Resources.LoseChangesQuestion;
_ = Resources.UnknownException;

Resources.hosts.ShouldNotBeNullOrEmpty();
Resources.InvalidHostEntries.ShouldNotBeNullOrEmpty();
Resources.InvalidHostnames.ShouldNotBeNullOrEmpty();
Resources.InvalidIPAddress.ShouldNotBeNullOrEmpty();
Resources.PingFailed.ShouldNotBeNullOrEmpty();
}

[TestMethod]
public void Resources_CultureRoundTrips()
{
var original = Resources.Culture;
try
{
Resources.Culture = System.Globalization.CultureInfo.InvariantCulture;
Resources.Culture.ShouldBe(System.Globalization.CultureInfo.InvariantCulture);
Resources.ArchiveExists.ShouldNotBeNull();
}
finally
{
Resources.Culture = original;
}
}
}
95 changes: 95 additions & 0 deletions HostsFileEditor.Core.Tests/ElevationHelperSelectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using HostsFileEditor.Elevation;

namespace HostsFileEditor.Core.Tests;

/// <summary>
/// Coverage for <see cref="PrivilegedFileOperations.UseElevationHelper"/>'s helper-resolution branches.
/// <see cref="PrivilegedFileOperations.Current"/> is process-global, so every test restores it.
/// </summary>
[TestClass]
public sealed class ElevationHelperSelectionTests
{
private IPrivilegedFileOperations _originalCurrent = null!;

[TestInitialize]
public void Init() => _originalCurrent = PrivilegedFileOperations.Current;

[TestCleanup]
public void Cleanup() => PrivilegedFileOperations.Current = _originalCurrent;

[TestMethod]
public void UseElevationHelper_ExplicitExistingPath_SelectsHelper()
{
var helper = Path.Combine(Path.GetTempPath(), "HfeHelper_" + Guid.NewGuid().ToString("N") + ".exe");
File.WriteAllText(helper, "not a real exe");
try
{
PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
PrivilegedFileOperations.UseElevationHelper(helper);
PrivilegedFileOperations.Current.ShouldBeOfType<ElevatedHelperPrivilegedFileOperations>();
}
finally
{
File.Delete(helper);
}
}

[TestMethod]
public void UseElevationHelper_ExplicitMissingPath_LeavesCurrentUnchanged()
{
var inProcess = new InProcessPrivilegedFileOperations();
PrivilegedFileOperations.Current = inProcess;

PrivilegedFileOperations.UseElevationHelper(Path.Combine(Path.GetTempPath(), "no-such-helper.exe"));

PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess);
}

[TestMethod]
public void UseElevationHelper_NoHelperBesideApp_StaysInProcess()
{
// No HostsFileEditor.Elevate.exe ships in the test bin. Defensively clear any stub a prior
// (interrupted) run of UseElevationHelper_HelperBesideApp_SelectsHelper may have left behind,
// so the default probe genuinely finds nothing regardless of test order.
foreach (var stray in HelperCandidatePaths())
{
if (File.Exists(stray))
{
File.Delete(stray);
}
}

var inProcess = new InProcessPrivilegedFileOperations();
PrivilegedFileOperations.Current = inProcess;

PrivilegedFileOperations.UseElevationHelper();

PrivilegedFileOperations.Current.ShouldBeSameAs(inProcess);
}

[TestMethod]
public void UseElevationHelper_HelperBesideApp_SelectsHelper()
{
// Drop a stand-in helper next to the test assembly so the default candidate probe finds it.
// The test bin never contains a real helper, so the stub is always removed afterwards.
var candidate = Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName);
File.WriteAllText(candidate, "stub");

try
{
PrivilegedFileOperations.Current = new InProcessPrivilegedFileOperations();
PrivilegedFileOperations.UseElevationHelper();
PrivilegedFileOperations.Current.ShouldBeOfType<ElevatedHelperPrivilegedFileOperations>();
}
finally
{
File.Delete(candidate);
}
}

private static IEnumerable<string> HelperCandidatePaths() =>
[
Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperSubdirectory, PrivilegedFileOperations.HelperExecutableName),
Path.Combine(AppContext.BaseDirectory, PrivilegedFileOperations.HelperExecutableName),
];
}
48 changes: 48 additions & 0 deletions HostsFileEditor.Core.Tests/HostsArchiveGapTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace HostsFileEditor.Core.Tests;

[TestClass]
public sealed class HostsArchiveGapTests
{
[TestMethod]
public void Validate_InvalidPath_ReturnsFalseWithMessage()
{
// An embedded null makes new FileInfo(...) throw, exercising Validate's catch path.
HostsArchive.Validate("bad\0name", out var error).ShouldBeFalse();
error.ShouldNotBeNullOrEmpty();
}

[TestMethod]
public void Constructor_WithName_UsesEffectiveArchiveDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(dir);
try
{
HostsArchiveList.TestArchiveDirectoryOverride = dir;
var archive = new HostsArchive("preset.txt");
archive.FilePath.ShouldBe(Path.Combine(dir, "preset.txt"));
archive.FileName.ShouldBe("preset.txt");
}
finally
{
HostsArchiveList.TestArchiveDirectoryOverride = null;
}
}

[TestMethod]
public void Constructor_NullName_Throws() =>
Should.Throw<ArgumentNullException>(() => new HostsArchive(null!));

[TestMethod]
public void FilePath_NullAssignment_Throws() =>
Should.Throw<ArgumentNullException>(() => new HostsArchive { FilePath = null! });

[TestMethod]
public void FileNameComparer_OrdersCaseInsensitively()
{
var a = new HostsArchive { FilePath = @"x\alpha.txt" };
var b = new HostsArchive { FilePath = @"x\BETA.txt" };
HostsArchive.FileNameComparer.Compare(a, b).ShouldBeLessThan(0);
HostsArchive.FileNameComparer.Compare(b, a).ShouldBeGreaterThan(0);
}
}
Loading
Loading