| 1 | using System.Net.Http.Json; |
| 2 | using System.Text.Json; |
| 3 | using AgentForge.Data; |
| 4 | using AgentForge.Models; |
| 5 | using Microsoft.Extensions.DependencyInjection; |
| 6 | |
| 7 | namespace AgentForge.Tests; |
| 8 | |
| 9 | public 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&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 | |