Forge
csharp3407750f
1using System.Net.Http.Json;
2using System.Text.Json;
3using AgentForge.Data;
4using AgentForge.Models;
5using Microsoft.Extensions.DependencyInjection;
6
7namespace AgentForge.Tests;
8
9public sealed class ForgeViewFeaturesTests(ForgeWebApplicationFactory factory) : IClassFixture<ForgeWebApplicationFactory>
10{
11 private readonly HttpClient _client = factory.CreateClient();
12 private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
13
14 [Fact]
15 public async Task Merge_request_diff_links_to_blob_lines()
16 {
17 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "lens-diff" });
18 factory.Git.CreateCommitOnBranch("lens-diff", "main", "README.md", "main", "main");
19 factory.Git.CreateCommitOnBranch("lens-diff", "feat/l", "src/L.cs", "line1\nline2\n", "feat");
20 await _client.PostAsJsonAsync("/api/v1/repos/lens-diff/merge-requests", new
21 {
22 title = "add L",
23 sourceBranch = "feat/l",
24 });
25
26 var view = await _client.GetAsync("/view/repos/lens-diff/merge-requests/1");
27 view.EnsureSuccessStatusCode();
28 var html = await view.Content.ReadAsStringAsync();
29
30 Assert.Contains("diff-file-link", html, StringComparison.Ordinal);
31 Assert.Contains("diff-num-link", html, StringComparison.Ordinal);
32 Assert.Contains("/view/repos/lens-diff/blob?branch=feat%2Fl&amp;path=src%2FL.cs", html, StringComparison.Ordinal);
33 }
34
35 [Fact]
36 public async Task Handoff_panel_appears_on_agent_driven_mr_view()
37 {
38 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "handoff-ui-sandbox", driveMode = "agent-driven" });
39 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "handoff-ui-canonical", driveMode = "human-driven" });
40 factory.Git.CreateCommitOnBranch("handoff-ui-sandbox", "main", "README.md", "m", "main");
41 factory.Git.CreateCommitOnBranch("handoff-ui-sandbox", "feat/p", "src/P.cs", "p", "feat");
42 factory.Git.CreateCommitOnBranch("handoff-ui-canonical", "main", "README.md", "m", "main");
43 factory.Git.CreateCommitOnBranch("handoff-ui-canonical", "feat/p", "src/P.cs", "p", "feat");
44 await _client.PostAsJsonAsync("/api/v1/repos/handoff-ui-sandbox/merge-requests", new
45 {
46 title = "handoff me",
47 sourceBranch = "feat/p",
48 });
49
50 var view = await _client.GetAsync("/view/repos/handoff-ui-sandbox/merge-requests/1");
51 view.EnsureSuccessStatusCode();
52 var html = await view.Content.ReadAsStringAsync();
53
54 Assert.Contains("iop-handoff", html, StringComparison.Ordinal);
55 Assert.Contains("handoff-ui-canonical", html, StringComparison.Ordinal);
56 Assert.Contains("Hand off", html, StringComparison.Ordinal);
57 }
58
59 [Fact]
60 public async Task Tree_view_accepts_tilde_url_path_for_grouped_repo()
61 {
62 const string fullName = "group-demo/tree-ui";
63 factory.Git.CreateBareRepository(fullName);
64 using (var scope = factory.Services.CreateScope())
65 {
66 scope.ServiceProvider.GetRequiredService<ForgeRepository>()
67 .InsertRepo(fullName, "", visibility: RepoVisibility.Public);
68 }
69 factory.Git.CreateCommitOnBranch(
70 fullName,
71 "main",
72 "docs/readme.md",
73 "# Docs",
74 "seed");
75
76 var view = await _client.GetAsync("/view/repos/group-demo~tree-ui/tree?branch=main&path=docs");
77 view.EnsureSuccessStatusCode();
78 var html = await view.Content.ReadAsStringAsync();
79
80 Assert.Contains("file-tree", html, StringComparison.Ordinal);
81 Assert.Contains("readme.md", html, StringComparison.Ordinal);
82 Assert.DoesNotContain("Git repository", html, StringComparison.Ordinal);
83 }
84
85 [Fact]
86 public async Task Docs_view_renders_markdown_from_git()
87 {
88 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "docs-ui", visibility = "public" });
89 factory.Git.CreateCommitOnBranch(
90 "docs-ui",
91 "main",
92 "docs/guide.md",
93 "# Guide\n\nHello **forge**.\n",
94 "docs");
95
96 var view = await _client.GetAsync("/view/repos/docs-ui/docs/guide.md");
97 view.EnsureSuccessStatusCode();
98 var html = await view.Content.ReadAsStringAsync();
99
100 Assert.Contains("doc-article", html, StringComparison.Ordinal);
101 Assert.Contains("Guide", html, StringComparison.Ordinal);
102 Assert.Contains("Hello", html, StringComparison.Ordinal);
103 Assert.Contains("<strong>forge</strong>", html, StringComparison.Ordinal);
104 Assert.DoesNotContain("/docs/docs/", html, StringComparison.Ordinal);
105 }
106
107 [Fact]
108 public async Task Merge_request_head_json_returns_source_tip()
109 {
110 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "mr-head" });
111 factory.Git.CreateCommitOnBranch("mr-head", "main", "README.md", "m", "main");
112 factory.Git.CreateCommitOnBranch("mr-head", "feat/h", "a.txt", "a", "feat");
113 await _client.PostAsJsonAsync("/api/v1/repos/mr-head/merge-requests", new
114 {
115 title = "h",
116 sourceBranch = "feat/h",
117 });
118
119 var head = await _client.GetFromJsonAsync<JsonElement>(
120 "/view/repos/mr-head/merge-requests/1/head.json",
121 JsonOptions);
122 Assert.False(string.IsNullOrWhiteSpace(head.GetProperty("sourceTip").GetString()));
123 Assert.Equal("open", head.GetProperty("status").GetString());
124 }
125
126 [Fact]
127 public async Task View_session_cookie_sets_avatar_initials()
128 {
129 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", "bootstrap-view-session");
130 using var localFactory = new ForgeWebApplicationFactory();
131 using var client = localFactory.CreateClient();
132 using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/tokens")
133 {
134 Content = JsonContent.Create(new { name = "DmitryK" }),
135 };
136 tokenRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
137 "Bearer",
138 "bootstrap-view-session");
139 var tokenResponse = await client.SendAsync(tokenRequest);
140 tokenResponse.EnsureSuccessStatusCode();
141 var tokenPayload = await tokenResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
142 var plainToken = tokenPayload.GetProperty("token").GetString()!;
143
144 client.DefaultRequestHeaders.Add("Cookie", $"{ForgeViewSessionCookieName}={plainToken}");
145
146 var view = await client.GetAsync("/view");
147 view.EnsureSuccessStatusCode();
148 var html = await view.Content.ReadAsStringAsync();
149 Assert.Contains(">DM<", html, StringComparison.Ordinal);
150 }
151
152 [Fact]
153 public async Task View_session_cookie_allows_capabilities_for_command_bar()
154 {
155 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", "true");
156 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", "bootstrap-view-capabilities");
157 try
158 {
159 using var localFactory = new ForgeWebApplicationFactory();
160 using var client = localFactory.CreateClient();
161 using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/admin/tokens")
162 {
163 Content = JsonContent.Create(new { name = "PaletteUser" }),
164 };
165 tokenRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
166 "Bearer",
167 "bootstrap-view-capabilities");
168 var tokenResponse = await client.SendAsync(tokenRequest);
169 tokenResponse.EnsureSuccessStatusCode();
170 var tokenPayload = await tokenResponse.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
171 var plainToken = tokenPayload.GetProperty("token").GetString()!;
172
173 client.DefaultRequestHeaders.Add("Cookie", $"{ForgeViewSessionCookieName}={plainToken}");
174
175 var response = await client.GetAsync("/api/v1/capabilities?surface=command-bar");
176 Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
177 var caps = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOptions);
178 Assert.True(caps.GetProperty("commands").GetArrayLength() > 0);
179 }
180 finally
181 {
182 Environment.SetEnvironmentVariable("FORGE_REQUIRE_AUTH", null);
183 Environment.SetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN", null);
184 }
185 }
186
187 [Fact]
188 public async Task Issue_view_includes_composer_markup()
189 {
190 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "composer-ui" });
191 await _client.PostAsJsonAsync("/api/v1/repos/composer-ui/issues", new { title = "Composer test" });
192
193 var view = await _client.GetAsync("/view/repos/composer-ui/issues/1");
194 view.EnsureSuccessStatusCode();
195 var html = await view.Content.ReadAsStringAsync();
196
197 Assert.Contains("forge-composer", html, StringComparison.Ordinal);
198 Assert.Contains("data-forge-surface=\"issue-comment\"", html, StringComparison.Ordinal);
199 Assert.Contains("/view/repos/composer-ui/issues/1/comment", html, StringComparison.Ordinal);
200 Assert.Contains("/view/forge-slash.js", html, StringComparison.Ordinal);
201 }
202
203 [Fact]
204 public async Task View_post_issue_comment_redirects_and_renders_comment()
205 {
206 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "comment-ui" });
207 await _client.PostAsJsonAsync("/api/v1/repos/comment-ui/issues", new { title = "Comment target" });
208
209 using var form = new FormUrlEncodedContent(new Dictionary<string, string>
210 {
211 ["body"] = "Hello from composer",
212 });
213 var client = factory.CreateClient(new() { AllowAutoRedirect = false });
214 var post = await client.PostAsync("/view/repos/comment-ui/issues/1/comment", form);
215 Assert.Equal(System.Net.HttpStatusCode.Redirect, post.StatusCode);
216 Assert.Equal("/view/repos/comment-ui/issues/1", post.Headers.Location?.OriginalString);
217
218 var view = await client.GetAsync("/view/repos/comment-ui/issues/1");
219 var html = await view.Content.ReadAsStringAsync();
220 Assert.Contains("Hello from composer", html, StringComparison.Ordinal);
221 }
222
223 [Fact]
224 public async Task Merge_request_conversation_includes_composer()
225 {
226 await _client.PostAsJsonAsync("/api/v1/repos", new { name = "mr-composer" });
227 factory.Git.CreateCommitOnBranch("mr-composer", "main", "README.md", "m", "main");
228 factory.Git.CreateCommitOnBranch("mr-composer", "feat/c", "a.txt", "a", "feat");
229 await _client.PostAsJsonAsync("/api/v1/repos/mr-composer/merge-requests", new
230 {
231 title = "composer mr",
232 sourceBranch = "feat/c",
233 });
234
235 var view = await _client.GetAsync("/view/repos/mr-composer/merge-requests/1?iop=conversation");
236 view.EnsureSuccessStatusCode();
237 var html = await view.Content.ReadAsStringAsync();
238
239 Assert.Contains("forge-composer", html, StringComparison.Ordinal);
240 Assert.Contains("/view/repos/mr-composer/merge-requests/1/comment", html, StringComparison.Ordinal);
241 }
242
243 private const string ForgeViewSessionCookieName = "forge_view_token";
244}
245
View only · write via MCP/CIDE