using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.TaskRunner.Core.Execution; using StellaOps.TaskRunner.Core.Planning; using StellaOps.TaskRunner.Infrastructure.Execution; using Xunit; using StellaOps.TestKit; namespace StellaOps.TaskRunner.Tests; public sealed class FilesystemPackRunArtifactUploaderTests : IDisposable { private readonly string artifactsRoot; public FilesystemPackRunArtifactUploaderTests() { artifactsRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CopiesFileOutputs() { var sourceFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():n}.txt"); await File.WriteAllTextAsync(sourceFile, "artifact-content", CancellationToken.None); var uploader = CreateUploader(); var output = CreateFileOutput("bundle", sourceFile); var context = CreateContext(); var state = CreateState(context); await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); var runPath = Path.Combine(artifactsRoot, context.RunId); var filesDirectory = Path.Combine(runPath, "files"); var copiedFiles = Directory.GetFiles(filesDirectory); Assert.Single(copiedFiles); Assert.Equal("bundle.txt", Path.GetFileName(copiedFiles[0])); Assert.Equal("artifact-content", await File.ReadAllTextAsync(copiedFiles[0], CancellationToken.None)); var manifest = await ReadManifestAsync(runPath); Assert.Single(manifest.Outputs); Assert.Equal("copied", manifest.Outputs[0].Status); Assert.Equal("files/bundle.txt", manifest.Outputs[0].StoredPath); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task RecordsMissingFilesWithoutThrowing() { var uploader = CreateUploader(); var output = CreateFileOutput("missing", Path.Combine(Path.GetTempPath(), "does-not-exist.txt")); var context = CreateContext(); var state = CreateState(context); await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId)); Assert.Equal("missing", manifest.Outputs[0].Status); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task WritesExpressionOutputsAsJson() { var uploader = CreateUploader(); var output = CreateExpressionOutput("metadata", JsonNode.Parse("""{"foo":"bar"}""")!); var context = CreateContext(); var state = CreateState(context); await uploader.UploadAsync(context, state, new[] { output }, CancellationToken.None); var expressionPath = Path.Combine(artifactsRoot, context.RunId, "expressions", "metadata.json"); Assert.True(File.Exists(expressionPath)); var manifest = await ReadManifestAsync(Path.Combine(artifactsRoot, context.RunId)); Assert.Equal("materialized", manifest.Outputs[0].Status); Assert.Equal("expressions/metadata.json", manifest.Outputs[0].StoredPath); } private FilesystemPackRunArtifactUploader CreateUploader() => new(artifactsRoot, TimeProvider.System, NullLogger.Instance); private static TaskPackPlanOutput CreateFileOutput(string name, string path) => new( name, Type: "file", Path: new TaskPackPlanParameterValue(JsonValue.Create(path), null, null, false), Expression: null); private static TaskPackPlanOutput CreateExpressionOutput(string name, JsonNode expression) => new( name, Type: "object", Path: null, Expression: new TaskPackPlanParameterValue(expression, null, null, false)); private static PackRunExecutionContext CreateContext() => new("run-" + Guid.NewGuid().ToString("n"), CreatePlan(), DateTimeOffset.UtcNow); private static PackRunState CreateState(PackRunExecutionContext context) => PackRunState.Create( runId: context.RunId, planHash: context.Plan.Hash, context.Plan, failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false), requestedAt: DateTimeOffset.UtcNow, steps: new Dictionary(StringComparer.Ordinal), timestamp: DateTimeOffset.UtcNow); private static TaskPackPlan CreatePlan() { return new TaskPackPlan( new TaskPackPlanMetadata("sample-pack", "1.0.0", null, Array.Empty()), new Dictionary(StringComparer.Ordinal), Array.Empty(), hash: "hash", approvals: Array.Empty(), secrets: Array.Empty(), outputs: Array.Empty(), failurePolicy: new TaskPackPlanFailurePolicy(1, 1, false)); } private static async Task ReadManifestAsync(string runPath) { var json = await File.ReadAllTextAsync(Path.Combine(runPath, "artifact-manifest.json"), CancellationToken.None); return JsonSerializer.Deserialize(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; } public void Dispose() { if (Directory.Exists(artifactsRoot)) { Directory.Delete(artifactsRoot, recursive: true); } } private sealed record ArtifactManifestModel(string RunId, DateTimeOffset UploadedAt, List Outputs); private sealed record ArtifactRecordModel(string Name, string Type, string? SourcePath, string? StoredPath, string Status, string? Notes); }