diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 583716d..7e07a1f 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -46,6 +46,9 @@ jobs:
*.nupkg
*.snupkg
+ - name: test SDK packages
+ run: dotnet test src/sdk.tests/Geekeey.Arcade.NET.Tests.csproj --no-build --no-restore
+
- name: publish to nuget
if: startsWith(forgejo.ref, 'refs/tags/')
shell: bash
diff --git a/.gitignore b/.gitignore
index a9a1271..0ab95b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
artifacts/
+TestResults/
node_modules/
*.DotSettings.user
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b8e7e93..649f17a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,5 +3,6 @@
true
+
diff --git a/src/sdk.tests/DirectoryExtensions.cs b/src/sdk.tests/DirectoryExtensions.cs
new file mode 100644
index 0000000..1b73e4d
--- /dev/null
+++ b/src/sdk.tests/DirectoryExtensions.cs
@@ -0,0 +1,23 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+namespace Geekeey.Arcade.NET.Tests;
+
+public static class DirectoryExtensions
+{
+ extension(Directory)
+ {
+ public static void Copy(string sourceDirectory, string destinationDirectory)
+ {
+ foreach (var directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ Directory.CreateDirectory(Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, directory)));
+ }
+
+ foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ File.Copy(file, Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, file)));
+ }
+ }
+ }
+}
diff --git a/src/sdk.tests/Geekeey.Arcade.NET.Tests.csproj b/src/sdk.tests/Geekeey.Arcade.NET.Tests.csproj
new file mode 100644
index 0000000..ae672d9
--- /dev/null
+++ b/src/sdk.tests/Geekeey.Arcade.NET.Tests.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0
+ false
+ true
+ Exe
+
+
+
+
+
+
+
+
+
diff --git a/src/sdk.tests/SdkPackageIntegrationTests.cs b/src/sdk.tests/SdkPackageIntegrationTests.cs
new file mode 100644
index 0000000..6fb64ac
--- /dev/null
+++ b/src/sdk.tests/SdkPackageIntegrationTests.cs
@@ -0,0 +1,171 @@
+// Copyright (c) The Geekeey Authors
+// SPDX-License-Identifier: EUPL-1.2
+
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+
+namespace Geekeey.Arcade.NET.Tests;
+
+public sealed class SdkPackageIntegrationTests
+{
+
+ private static string RepositoryPath
+ {
+ get
+ {
+ for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "global.json")))
+ {
+ return directory.FullName;
+ }
+ }
+
+ throw new InvalidOperationException("Could not locate the repository root containing global.json.");
+ }
+ }
+
+ private static string NuGetFeedPath { get; } = Path.Combine(RepositoryPath, "artifacts", "package", "release");
+
+ [Test]
+ [Arguments("Geekeey.Arcade.NET.Sdk", "base")]
+ [Arguments("Geekeey.Arcade.NET.Sdk.Razor", "razor")]
+ [Arguments("Geekeey.Arcade.NET.Sdk.Web", "web")]
+ public async Task SdkPackageCanBeConsumedFromAFeed(string packageId, string name)
+ {
+ // Arrange
+ var version = FindPackageVersion(packageId);
+ using var sut = await SdkTestProject.CreateAsync(name, version);
+
+ // Act
+ await sut.RunDotnetAsync($"restore --configfile \"{sut.NuGetConfigPath}\" --packages \"{sut.PackagesPath}\" --nologo");
+ await sut.RunDotnetAsync("build --no-restore --nologo");
+ await sut.RunDotnetAsync($"pack --no-restore --configuration Debug --output \"{sut.PackedPath}\" --nologo");
+
+ // Assert
+ await Assert.That(File.Exists(Path.Combine(sut.ProjectPath, "bin", "Debug", "net10.0", $"{sut.ProjectName}.dll"))).IsTrue();
+ await Assert.That(Directory.Exists(sut.PackedPath)).IsTrue();
+ await Assert.That(Directory.EnumerateFiles(sut.PackedPath, $"{sut.ProjectName}.*.nupkg").Any()).IsTrue();
+ }
+
+ [Test]
+ public async Task TestSdkPackageStoresTestOutputInArtifacts()
+ {
+ // Arrange
+ var version = FindPackageVersion("Geekeey.Arcade.NET.Sdk.Test");
+ using var sut = await SdkTestProject.CreateAsync("test", version);
+ await sut.RunDotnetAsync($"restore --configfile \"{sut.NuGetConfigPath}\" --packages \"{sut.PackagesPath}\" --nologo");
+ await sut.RunDotnetAsync("build --no-restore --nologo");
+
+ var outputDirectory = Path.Combine(sut.ProjectPath, "artifacts", "bin", sut.ProjectName, "debug");
+ await Assert.That(File.Exists(Path.Combine(outputDirectory, $"{sut.ProjectName}.dll"))).IsTrue();
+ var testConfig = await File.ReadAllTextAsync(Path.Combine(outputDirectory, $"{sut.ProjectName}.testconfig.json"));
+ await Assert.That(testConfig.Contains("results-directory", StringComparison.Ordinal)).IsTrue();
+
+ // Act
+ var stdout = await sut.RunDotnetAsync("run --no-build --no-restore");
+
+ // Assert
+ await Assert.That(stdout.Contains("succeeded: 1", StringComparison.Ordinal)).IsTrue();
+ await Assert.That(Directory.EnumerateFiles(Path.Combine(sut.ProjectPath, "artifacts", "testresults"), $"{sut.ProjectName}-*").Any()).IsTrue();
+ }
+
+ private static string FindPackageVersion(string packageId)
+ {
+ var pattern = $"""^{Regex.Escape(packageId)}\.(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.nupkg$""";
+ foreach (var package in Directory.EnumerateFiles(NuGetFeedPath, "*.nupkg"))
+ {
+ var match = Regex.Match(Path.GetFileName(package), pattern, RegexOptions.CultureInvariant);
+ if (match.Success)
+ {
+ return match.Groups["version"].Value;
+ }
+ }
+
+ throw new InvalidOperationException($"No current package found for {packageId}. Run dotnet pack first.");
+ }
+
+ private sealed class SdkTestProject : IDisposable
+ {
+ private SdkTestProject(string root)
+ {
+ RootPath = root;
+ ProjectPath = Path.Combine(RootPath, "Project");
+ PackagesPath = Path.Combine(RootPath, "packages");
+ PackedPath = Path.Combine(RootPath, "packed");
+ NuGetConfigPath = Path.Combine(RootPath, "NuGet.config");
+ }
+
+ public static async Task CreateAsync(string name, string version)
+ {
+ var sut = new SdkTestProject(Path.Combine(Path.GetTempPath(), $"arcade-sdk-test-{Guid.NewGuid():N}"));
+ try
+ {
+ Directory.CreateDirectory(sut.ProjectPath);
+ Directory.Copy(Path.Combine(AppContext.BaseDirectory, "fixtures", name), sut.ProjectPath);
+ sut.ProjectFilePath = Directory.EnumerateFiles(sut.ProjectPath, "*.csproj").Single();
+ sut.ProjectName = Path.GetFileNameWithoutExtension(sut.ProjectFilePath);
+ var project = await File.ReadAllTextAsync(sut.ProjectFilePath);
+ await File.WriteAllTextAsync(sut.ProjectFilePath, project.Replace("{0}", version, StringComparison.Ordinal));
+ var contents = $"""
+
+
+
+
+
+
+
+ """;
+ await File.WriteAllTextAsync(sut.NuGetConfigPath, contents);
+ return sut;
+ }
+ catch
+ {
+ sut.Dispose();
+ throw;
+ }
+ }
+
+ public string RootPath { get; }
+
+ public string ProjectPath { get; }
+
+ public string ProjectFilePath { get; private set; } = "";
+
+ public string ProjectName { get; private set; } = "";
+
+ public string PackagesPath { get; }
+
+ public string PackedPath { get; }
+
+ public string NuGetConfigPath { get; }
+
+ public async Task RunDotnetAsync(string arguments)
+ {
+ var startInfo = new ProcessStartInfo("dotnet", arguments)
+ {
+ WorkingDirectory = ProjectPath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ Environment = { ["NUGET_PACKAGES"] = PackagesPath, ["DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER"] = "1" }
+ };
+ var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start dotnet.");
+ var output = await process.StandardOutput.ReadToEndAsync();
+ var error = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+ if (process.ExitCode is not 0)
+ {
+ throw new InvalidOperationException($"dotnet {arguments} failed with exit code {process.ExitCode}.\n{output}\n{error}");
+ }
+
+ return output;
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(RootPath, recursive: true);
+ }
+ }
+}
diff --git a/src/sdk.tests/fixtures/base/BaseConsumer.csproj b/src/sdk.tests/fixtures/base/BaseConsumer.csproj
new file mode 100644
index 0000000..65f8f3f
--- /dev/null
+++ b/src/sdk.tests/fixtures/base/BaseConsumer.csproj
@@ -0,0 +1,9 @@
+
+
+ net10.0
+ true
+ false
+ BaseConsumer
+ IDE0055
+
+
diff --git a/src/sdk.tests/fixtures/base/Program.cs b/src/sdk.tests/fixtures/base/Program.cs
new file mode 100644
index 0000000..ddbad5a
--- /dev/null
+++ b/src/sdk.tests/fixtures/base/Program.cs
@@ -0,0 +1,4 @@
+public static class EntryPoint
+{
+ public static string Value => "ok";
+}
diff --git a/src/sdk.tests/fixtures/razor/Program.cs b/src/sdk.tests/fixtures/razor/Program.cs
new file mode 100644
index 0000000..a150b55
--- /dev/null
+++ b/src/sdk.tests/fixtures/razor/Program.cs
@@ -0,0 +1,4 @@
+public sealed class ComponentModel
+{
+ public string Value => "ok";
+}
diff --git a/src/sdk.tests/fixtures/test/Directory.Build.props b/src/sdk.tests/fixtures/test/Directory.Build.props
new file mode 100644
index 0000000..1b15241
--- /dev/null
+++ b/src/sdk.tests/fixtures/test/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+ $(MSBuildProjectDirectory)/artifacts/
+
+
diff --git a/src/sdk.tests/fixtures/test/Program.cs b/src/sdk.tests/fixtures/test/Program.cs
new file mode 100644
index 0000000..be2f92a
--- /dev/null
+++ b/src/sdk.tests/fixtures/test/Program.cs
@@ -0,0 +1,7 @@
+using TUnit.Core;
+
+public class ConsumerTests
+{
+ [Test]
+ public void TestPasses() { }
+}
diff --git a/src/sdk.tests/fixtures/test/TestConsumer.csproj b/src/sdk.tests/fixtures/test/TestConsumer.csproj
new file mode 100644
index 0000000..6f86a2d
--- /dev/null
+++ b/src/sdk.tests/fixtures/test/TestConsumer.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ true
+ false
+ TestConsumer
+ true
+ Exe
+ IDE0055
+
+
+
+
+
diff --git a/src/sdk.tests/fixtures/web/Program.cs b/src/sdk.tests/fixtures/web/Program.cs
new file mode 100644
index 0000000..971a50d
--- /dev/null
+++ b/src/sdk.tests/fixtures/web/Program.cs
@@ -0,0 +1,3 @@
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+app.MapGet("/", () => "ok");
diff --git a/src/sdk.tests/fixtures/web/WebConsumer.csproj b/src/sdk.tests/fixtures/web/WebConsumer.csproj
new file mode 100644
index 0000000..3d9e2d9
--- /dev/null
+++ b/src/sdk.tests/fixtures/web/WebConsumer.csproj
@@ -0,0 +1,10 @@
+
+
+ net10.0
+ true
+ false
+ WebConsumer
+ Exe
+ IDE0055
+
+