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
22 changes: 17 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ env:

jobs:
core-tests:
name: Core build + tests (ubuntu)
name: Core + Execution build + tests (ubuntu)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand All @@ -39,13 +39,20 @@ jobs:
- name: Restore Core projects
run: |
dotnet restore src/Snipdeck.Core/Snipdeck.Core.csproj
dotnet restore src/Snipdeck.Execution/Snipdeck.Execution.csproj
dotnet restore tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj
dotnet restore tests/Snipdeck.Execution.Tests/Snipdeck.Execution.Tests.csproj

- name: Build Core
run: dotnet build src/Snipdeck.Core/Snipdeck.Core.csproj --configuration Release --no-restore
run: |
dotnet build src/Snipdeck.Core/Snipdeck.Core.csproj --configuration Release --no-restore
dotnet build src/Snipdeck.Execution/Snipdeck.Execution.csproj --configuration Release --no-restore

- name: Build + run Core tests
run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=core-tests.trx" --results-directory TestResults
run: dotnet test --project tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore --report-xunit-trx --report-xunit-trx-filename core-tests.trx --results-directory TestResults

- name: Build + run Execution tests
run: dotnet test --project tests/Snipdeck.Execution.Tests/Snipdeck.Execution.Tests.csproj --configuration Release --no-restore --report-xunit-trx --report-xunit-trx-filename execution-tests.trx --results-directory TestResults

- name: Upload test results
if: always()
Expand Down Expand Up @@ -80,7 +87,7 @@ jobs:
run: dotnet build tools/Snipdeck.Importer/Snipdeck.Importer.csproj --configuration Release --no-restore

- name: Build + run importer tests
run: dotnet test tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=importer-tests.trx" --results-directory TestResults
run: dotnet test --project tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj --configuration Release --no-restore --report-xunit-trx --report-xunit-trx-filename importer-tests.trx --results-directory TestResults

- name: Upload test results
if: always()
Expand Down Expand Up @@ -113,7 +120,12 @@ jobs:
run: dotnet build --configuration Release --no-restore

- name: Run Core tests (sanity check on Windows)
run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=core-tests-windows.trx" --results-directory TestResults
run: dotnet test --project tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-build --report-xunit-trx --report-xunit-trx-filename core-tests-windows.trx --results-directory TestResults

# Execution wraps ConPTY and process launching, so Windows is the platform
# that actually matters for it — the ubuntu run only proves it is portable.
- name: Run Execution tests (ConPTY + process launching are platform-specific)
run: dotnet test --project tests/Snipdeck.Execution.Tests/Snipdeck.Execution.Tests.csproj --configuration Release --no-build --report-xunit-trx --report-xunit-trx-filename execution-tests-windows.trx --results-directory TestResults

- name: Upload test results
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
run: dotnet restore

- name: Run Core tests
run: dotnet test tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore
run: dotnet test --project tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj --configuration Release --no-restore

- name: Publish Snipdeck.App (win-x64)
# No -p:Version here — Nerdbank.GitVersioning stamps the assemblies at
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Migrated the test suites to xUnit v3 on Microsoft Testing Platform, replacing
the VSTest stack. Development-only; the shipped app is unaffected.

### Fixed
- CI now runs the `Snipdeck.Execution` test suite, on both Ubuntu and Windows.
It was built but never executed, so 61 tests were only ever run locally.

### Changed
- Refreshed dependencies to their latest stable releases, including the Windows
App SDK (2.4.0), WebView2, the Windows SDK build tools, SQLite, the dependency
Expand Down
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ dotnet build
# Build Core only (works on Linux / macOS)
dotnet build src/Snipdeck.Core

# Run Core tests
dotnet test tests/Snipdeck.Core.Tests
# Run the tests (Core, Execution, importer)
dotnet test --project tests/Snipdeck.Core.Tests
dotnet test --project tests/Snipdeck.Execution.Tests
dotnet test --project tools/Snipdeck.Importer.Tests
```

The test projects are **xUnit v3**, which runs on Microsoft Testing Platform
rather than VSTest. The repo-root `global.json` opts `dotnet test` into that
runner, which is why the project is passed as `--project <path>` rather than as a
bare argument. TRX reports come from `--report-xunit-trx`, not `--logger trx`.

On a non-Windows machine, restoring the `Snipdeck.App` project requires
`EnableWindowsTargeting=true`:

Expand Down
12 changes: 7 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@
<PackageVersion Include="Spectre.Console.Cli" Version="0.55.0" />
</ItemGroup>

<!-- Tests -->
<!-- Tests. xunit v3 ships under the xunit.v3 package ID (the old `xunit` ID is
the feature-frozen v2 line) and self-hosts on Microsoft Testing Platform v2,
so it replaces the whole VSTest stack: no Microsoft.NET.Test.Sdk, no
xunit.runner.visualstudio, and coverage comes from the MTP extension rather
than coverlet.collector (a VSTest data collector, inert under MTP). -->
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
</ItemGroup>

</Project>
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ Requirements:
# Restore + build everything
dotnet build

# Run Core unit tests
dotnet test tests/Snipdeck.Core.Tests
# Run the unit tests
dotnet test --project tests/Snipdeck.Core.Tests
dotnet test --project tests/Snipdeck.Execution.Tests
```

The `Snipdeck.Core` project targets `net10.0` and is fully portable, so
Expand All @@ -137,10 +138,10 @@ Snipdeck is built on the work of these open-source projects, with thanks to
their authors and maintainers:

- [.NET Community Toolkit](https://github.com/CommunityToolkit/dotnet) — MVVM source generators and helpers (MIT)
- [Coverlet](https://github.com/coverlet-coverage/coverlet) — code-coverage collection (MIT)
- [H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon) — system-tray icon and menu (MIT)
- [Jdenticon](https://github.com/dmester/jdenticon-net) — identicons for CLIs without a custom icon (MIT)
- [Markdig](https://github.com/xoofx/markdig) — Markdown rendering for Snip descriptions (BSD-2-Clause)
- [Microsoft Testing Platform](https://github.com/microsoft/testfx) — runs the test suites and collects code coverage (MIT)
- [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) — dependency injection (MIT)
- [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) — git-derived versioning (MIT)
- [Porta.Pty](https://github.com/tomlm/Porta.Pty) — cross-platform pseudo-terminal (ConPTY) for running commands (MIT)
Expand Down
5 changes: 5 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
2 changes: 1 addition & 1 deletion src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public SettingsViewModel(
new("Spectre.Console", "Console UI for the SnipCommand import tool.", new Uri("https://github.com/spectreconsole/spectre.console"), "MIT"),
new("Nerdbank.GitVersioning", "Derives the version from git history.", new Uri("https://github.com/dotnet/Nerdbank.GitVersioning"), "MIT"),
new("xUnit", "Unit-testing framework.", new Uri("https://github.com/xunit/xunit"), "Apache-2.0"),
new("Coverlet", "Code-coverage collection for tests.", new Uri("https://github.com/coverlet-coverage/coverlet"), "MIT"),
new("Microsoft Testing Platform", "Runs the test suites and collects code coverage.", new Uri("https://github.com/microsoft/testfx"), "MIT"),
];

[ObservableProperty]
Expand Down
4 changes: 2 additions & 2 deletions tests/Snipdeck.Core.Tests/Engine/SubstitutionEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ public void Repeated_missing_token_is_listed_once_only()
new Dictionary<string, string?>());

Assert.Equal("{missing} and {missing} again", result.Text);
Assert.Single(result.UnresolvedTokens);
Assert.Equal("missing", result.UnresolvedTokens[0]);
var token = Assert.Single(result.UnresolvedTokens);
Assert.Equal("missing", token);
}

[Fact]
Expand Down
38 changes: 19 additions & 19 deletions tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public async Task CreateBackupAsync_returns_null_when_source_missing()
var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero));
var service = BuildService(clock);

var info = await service.CreateBackupAsync();
var info = await service.CreateBackupAsync(TestContext.Current.CancellationToken);

Assert.Null(info);
Assert.False(Directory.Exists(_backupDirectory) && Directory.EnumerateFiles(_backupDirectory).Any());
Expand All @@ -67,12 +67,12 @@ public async Task CreateBackupAsync_copies_source_to_timestamped_destination()
var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 34, 56, 789, TimeSpan.Zero));
var service = BuildService(clock);

var info = await service.CreateBackupAsync();
var info = await service.CreateBackupAsync(TestContext.Current.CancellationToken);

Assert.NotNull(info);
Assert.True(File.Exists(info!.FilePath));
Assert.EndsWith("snipstore_20260529_123456789.json", info.FilePath);
Assert.Equal("hello", await File.ReadAllTextAsync(info.FilePath));
Assert.Equal("hello", await File.ReadAllTextAsync(info.FilePath, TestContext.Current.CancellationToken));
Assert.Equal(5, info.SizeBytes);
Assert.Equal(clock.UtcNow, info.CreatedAtUtc);
}
Expand All @@ -84,7 +84,7 @@ public async Task CreateBackupAsync_creates_missing_backup_directory()
Assert.False(Directory.Exists(_backupDirectory));

var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero));
await BuildService(clock).CreateBackupAsync();
await BuildService(clock).CreateBackupAsync(TestContext.Current.CancellationToken);

Assert.True(Directory.Exists(_backupDirectory));
}
Expand All @@ -97,9 +97,9 @@ public async Task CreateBackupAsync_appends_collision_suffix_when_clock_repeats(
var clock = new FakeClock(fixedTime);
var service = BuildService(clock);

var first = await service.CreateBackupAsync();
var second = await service.CreateBackupAsync();
var third = await service.CreateBackupAsync();
var first = await service.CreateBackupAsync(TestContext.Current.CancellationToken);
var second = await service.CreateBackupAsync(TestContext.Current.CancellationToken);
var third = await service.CreateBackupAsync(TestContext.Current.CancellationToken);

Assert.NotEqual(first!.FilePath, second!.FilePath);
Assert.NotEqual(second.FilePath, third!.FilePath);
Expand All @@ -117,7 +117,7 @@ public async Task CreateBackupAsync_prunes_backups_beyond_retention()

for (var i = 0; i < 5; i++)
{
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(1));
}

Expand All @@ -143,14 +143,14 @@ public async Task CreateBackupAsync_reads_retention_lazily_from_provider()
// Fill up under the initial retention of 5.
for (var i = 0; i < 5; i++)
{
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(1));
}
Assert.Equal(5, Directory.GetFiles(_backupDirectory, "snipstore_*.json").Length);

// Tighten retention and back up again: the next prune honours the new value.
retention = 2;
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);

Assert.Equal(2, Directory.GetFiles(_backupDirectory, "snipstore_*.json").Length);
}
Expand All @@ -164,7 +164,7 @@ public async Task CreateBackupAsync_clamps_non_positive_provider_value_to_one()

for (var i = 0; i < 4; i++)
{
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(1));
}

Expand All @@ -186,19 +186,19 @@ public async Task PruneStep_does_not_touch_unrelated_files_in_backup_directory()
WriteSource();
Directory.CreateDirectory(_backupDirectory);
var sibling = Path.Combine(_backupDirectory, "not-a-backup.txt");
await File.WriteAllTextAsync(sibling, "untouched");
await File.WriteAllTextAsync(sibling, "untouched", TestContext.Current.CancellationToken);

var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero));
var service = BuildService(clock, retention: 1);

for (var i = 0; i < 5; i++)
{
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(1));
}

Assert.True(File.Exists(sibling));
Assert.Equal("untouched", await File.ReadAllTextAsync(sibling));
Assert.Equal("untouched", await File.ReadAllTextAsync(sibling, TestContext.Current.CancellationToken));
}

[Fact]
Expand All @@ -208,13 +208,13 @@ public async Task ListBackupsAsync_returns_newest_first_with_parsed_timestamps()
var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero));
var service = BuildService(clock);

await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(5));
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);
clock.Advance(TimeSpan.FromSeconds(5));
await service.CreateBackupAsync();
await service.CreateBackupAsync(TestContext.Current.CancellationToken);

var list = await service.ListBackupsAsync();
var list = await service.ListBackupsAsync(TestContext.Current.CancellationToken);

Assert.Equal(3, list.Count);
Assert.True(list[0].CreatedAtUtc > list[1].CreatedAtUtc);
Expand All @@ -227,7 +227,7 @@ public async Task ListBackupsAsync_returns_empty_when_directory_missing()
var clock = new FakeClock(DateTimeOffset.UtcNow);
var service = BuildService(clock);

var list = await service.ListBackupsAsync();
var list = await service.ListBackupsAsync(TestContext.Current.CancellationToken);

Assert.Empty(list);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ public async Task Seed_round_trips_through_the_json_store()
var store = new JsonSnipStore(Path.Combine(tempDir, "store.json"));
var original = ExamplesSeed.Build();

await store.SaveAsync(original);
var loaded = await store.LoadAsync();
await store.SaveAsync(original, TestContext.Current.CancellationToken);
var loaded = await store.LoadAsync(TestContext.Current.CancellationToken);

Assert.Equal(original.Clis.Count, loaded.Clis.Count);
Assert.Equal(original.Snips.Count, loaded.Snips.Count);
Expand Down
4 changes: 2 additions & 2 deletions tests/Snipdeck.Core.Tests/Services/IconAssetStorageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task DeleteIconAsync_ignores_parent_directory_traversal()
{
// A file outside the icons directory that a malformed IconRef tries to reach.
var victim = Path.Combine(_baseDirectory, "important.txt");
await File.WriteAllTextAsync(victim, "keep me");
await File.WriteAllTextAsync(victim, "keep me", TestContext.Current.CancellationToken);

await _storage.DeleteIconAsync("../important.txt");

Expand All @@ -62,7 +62,7 @@ public async Task DeleteIconAsync_ignores_parent_directory_traversal()
public async Task DeleteIconAsync_ignores_absolute_paths()
{
var victim = Path.Combine(_baseDirectory, "outside.txt");
await File.WriteAllTextAsync(victim, "keep me");
await File.WriteAllTextAsync(victim, "keep me", TestContext.Current.CancellationToken);

// Path.Combine(base, absolute) discards base — an unguarded delete would hit this.
await _storage.DeleteIconAsync(victim);
Expand Down
Loading
Loading