Forge
csharp3407750f
1using System.Net;
2using System.Net.Http.Json;
3using System.Text.Json;
4using AgentForge.Plugin.Ci.Contracts;
5using AgentForge.Plugin.Ci.Ir;
6
7namespace AgentForge.Tests;
8
9public sealed class ForgeCiApiTests : IAsyncLifetime, IDisposable
10{
11 private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
12
13 private ForgeWebApplicationFactory? _factory;
14 private HttpClient? _client;
15
16 public Task InitializeAsync()
17 {
18 Environment.SetEnvironmentVariable("FORGE_CI_DRY_RUN", "1");
19 _factory = new ForgeWebApplicationFactory { PluginBundle = "ci" };
20 _client = _factory.CreateClient();
21 return Task.CompletedTask;
22 }
23
24 public Task DisposeAsync()
25 {
26 Dispose();
27 return Task.CompletedTask;
28 }
29
30 public void Dispose()
31 {
32 Environment.SetEnvironmentVariable("FORGE_CI_DRY_RUN", null);
33 _client?.Dispose();
34 _factory?.Dispose();
35 }
36
37 [Fact]
38 public async Task Create_and_get_ci_definition_round_trips_ir()
39 {
40 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-pipeline-repo" });
41
42 var create = await _client.PostAsJsonAsync(
43 "/api/v1/repos/ci-pipeline-repo/ci/definitions",
44 new CreateCiDefinitionRequest("default", null));
45 Assert.Equal(HttpStatusCode.Created, create.StatusCode);
46
47 var detail = await _client.GetFromJsonAsync<CiDefinitionDetailResponse>(
48 "/api/v1/repos/ci-pipeline-repo/ci/definitions/default",
49 JsonOptions);
50 Assert.NotNull(detail);
51 Assert.Equal("default", detail!.Document.Name);
52 Assert.Equal(ForgeCiDocument.SchemaVersion, detail.Document.Schema);
53 Assert.True(detail.Document.Nodes.Count >= 4);
54 }
55
56 [Fact]
57 public async Task Task_catalog_lists_builtin_tasks()
58 {
59 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-tasks-repo" });
60 var catalog = await _client.GetFromJsonAsync<CiTaskCatalogResponse>(
61 "/api/v1/repos/ci-tasks-repo/ci/tasks",
62 JsonOptions);
63 Assert.NotNull(catalog);
64 Assert.Contains(catalog!.Tasks, t => t.Id == "dotnet.build");
65 }
66
67 [Fact]
68 public async Task Ci_tab_lists_created_pipeline()
69 {
70 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-view-repo" });
71 await _client.PostAsJsonAsync(
72 "/api/v1/repos/ci-view-repo/ci/definitions",
73 new CreateCiDefinitionRequest("default", null));
74
75 var html = await _client.GetStringAsync("/view/repos/ci-view-repo/ci");
76 Assert.Contains("default", html, StringComparison.Ordinal);
77 Assert.Contains("New pipeline", html, StringComparison.Ordinal);
78 }
79
80 [Fact]
81 public async Task Ci_bundle_registers_slash_commands()
82 {
83 var caps = await _client!.GetFromJsonAsync<JsonElement>("/api/v1/capabilities", JsonOptions);
84 var ids = caps.GetProperty("commands").EnumerateArray()
85 .Select(c => c.GetProperty("commandId").GetString())
86 .ToHashSet(StringComparer.Ordinal);
87
88 Assert.Contains("forge.ci.open", ids);
89 Assert.Contains("forge.ci.list", ids);
90 Assert.Contains("forge.ci.create", ids);
91 Assert.Contains("forge.ci.run", ids);
92 Assert.Contains("forge.ci.status", ids);
93 }
94
95 [Fact]
96 public async Task Execute_ci_open_redirects_to_tab_or_designer()
97 {
98 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-cmd-repo" });
99 await _client.PostAsJsonAsync(
100 "/api/v1/repos/ci-cmd-repo/ci/definitions",
101 new CreateCiDefinitionRequest("default", null));
102
103 var client = _factory!.CreateClient(new() { AllowAutoRedirect = false });
104 var ctx = new Dictionary<string, string> { ["repo"] = "ci-cmd-repo" };
105
106 var listPage = await client.PostAsJsonAsync("/api/v1/commands/execute", new { path = "/ci open", context = ctx });
107 Assert.Equal(HttpStatusCode.Redirect, listPage.StatusCode);
108 Assert.Equal("/view/repos/ci-cmd-repo/ci", listPage.Headers.Location?.OriginalString);
109
110 var designer = await client.PostAsJsonAsync(
111 "/api/v1/commands/execute",
112 new { path = "/ci open", args = "default", context = ctx });
113 Assert.Equal(HttpStatusCode.Redirect, designer.StatusCode);
114 Assert.Equal("/view/repos/ci-cmd-repo/ci/definitions/default/designer", designer.Headers.Location?.OriginalString);
115 }
116
117 [Fact]
118 public async Task Execute_ci_create_redirects_to_designer()
119 {
120 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-create-cmd-repo" });
121
122 var client = _factory!.CreateClient(new() { AllowAutoRedirect = false });
123 var response = await client.PostAsJsonAsync(
124 "/api/v1/commands/execute",
125 new
126 {
127 path = "/ci create",
128 args = "release",
129 context = new Dictionary<string, string> { ["repo"] = "ci-create-cmd-repo" },
130 });
131
132 Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
133 Assert.Equal("/view/repos/ci-create-cmd-repo/ci/definitions/release/designer", response.Headers.Location?.OriginalString);
134 }
135
136 [Fact]
137 public async Task Queue_run_completes_and_updates_kernel_ci_for_mr()
138 {
139 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-run-repo" });
140 await _client.PostAsJsonAsync(
141 "/api/v1/repos/ci-run-repo/ci/definitions",
142 new CreateCiDefinitionRequest("default", null));
143 await _client.PostAsJsonAsync("/api/v1/repos/ci-run-repo/merge-requests", new
144 {
145 title = "ci run test",
146 sourceBranch = "feat/ci-run",
147 targetBranch = "main",
148 });
149
150 var queue = await _client.PostAsJsonAsync(
151 "/api/v1/repos/ci-run-repo/ci/runs",
152 new QueueCiRunRequest("default", "main", null, 1));
153 Assert.Equal(HttpStatusCode.Accepted, queue.StatusCode);
154
155 var queued = await queue.Content.ReadFromJsonAsync<CiRunDetailResponse>(JsonOptions);
156 Assert.NotNull(queued);
157 Assert.Equal("success", queued!.Status);
158 Assert.All(queued.Steps, s => Assert.Equal("success", s.Status));
159
160 var mrCi = await _client.GetFromJsonAsync<JsonElement>(
161 "/api/v1/repos/ci-run-repo/merge-requests/1/ci",
162 JsonOptions);
163 Assert.Equal("success", mrCi.GetProperty("status").GetString());
164 }
165
166 [Fact]
167 public async Task Execute_ci_run_redirects_to_run_timeline()
168 {
169 await _client!.PostAsJsonAsync("/api/v1/repos", new { name = "ci-run-cmd-repo" });
170 await _client.PostAsJsonAsync(
171 "/api/v1/repos/ci-run-cmd-repo/ci/definitions",
172 new CreateCiDefinitionRequest("default", null));
173
174 var client = _factory!.CreateClient(new() { AllowAutoRedirect = false });
175 var response = await client.PostAsJsonAsync(
176 "/api/v1/commands/execute",
177 new
178 {
179 path = "/ci run",
180 args = "default",
181 context = new Dictionary<string, string> { ["repo"] = "ci-run-cmd-repo" },
182 });
183
184 Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
185 var location = response.Headers.Location?.OriginalString;
186 Assert.NotNull(location);
187 Assert.StartsWith("/view/repos/ci-run-cmd-repo/ci/runs/", location, StringComparison.Ordinal);
188
189 var runId = location["/view/repos/ci-run-cmd-repo/ci/runs/".Length..];
190 var finished = await WaitForTerminalRunAsync("ci-run-cmd-repo", runId);
191 Assert.Equal("success", finished.Status);
192
193 var html = await _client.GetStringAsync(location);
194 Assert.Contains("ci-timeline", html, StringComparison.Ordinal);
195 Assert.Contains("success", html, StringComparison.Ordinal);
196 }
197
198 private async Task<CiRunDetailResponse> WaitForTerminalRunAsync(string repo, string runId)
199 {
200 for (var attempt = 0; attempt < 100; attempt++)
201 {
202 var detail = await _client!.GetFromJsonAsync<CiRunDetailResponse>(
203 $"/api/v1/repos/{repo}/ci/runs/{runId}",
204 JsonOptions);
205 Assert.NotNull(detail);
206 if (detail!.Status is "success" or "failure" or "cancelled")
207 return detail;
208
209 await Task.Delay(50);
210 }
211
212 throw new TimeoutException($"Run '{runId}' did not finish.");
213 }
214}
215
View only · write via MCP/CIDE