webctx 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +97 -0
  2. package/CONTRIBUTORS.md +126 -0
  3. package/LICENSE +202 -0
  4. package/Makefile +69 -0
  5. package/README.md +121 -0
  6. package/bin/webctx.js +28 -0
  7. package/cmd/webctx/main.go +11 -0
  8. package/docs/porting-status.md +173 -0
  9. package/go.mod +3 -0
  10. package/internal/app/app.go +139 -0
  11. package/internal/app/app_test.go +194 -0
  12. package/internal/app/github.go +2084 -0
  13. package/internal/app/github_acceptance_test.go +166 -0
  14. package/internal/app/github_actions.go +1008 -0
  15. package/internal/app/github_actions_test.go +484 -0
  16. package/internal/app/github_activity_deployments.go +503 -0
  17. package/internal/app/github_activity_deployments_test.go +321 -0
  18. package/internal/app/github_commits.go +1004 -0
  19. package/internal/app/github_commits_test.go +503 -0
  20. package/internal/app/github_discussions_gists.go +1156 -0
  21. package/internal/app/github_discussions_gists_test.go +425 -0
  22. package/internal/app/github_issues.go +1616 -0
  23. package/internal/app/github_issues_test.go +731 -0
  24. package/internal/app/github_packages_projects.go +429 -0
  25. package/internal/app/github_packages_projects_test.go +168 -0
  26. package/internal/app/github_phase9_overview_stress_test.go +178 -0
  27. package/internal/app/github_pull_views.go +1141 -0
  28. package/internal/app/github_pull_views_test.go +603 -0
  29. package/internal/app/github_pulls.go +1459 -0
  30. package/internal/app/github_pulls_test.go +715 -0
  31. package/internal/app/github_refs.go +621 -0
  32. package/internal/app/github_refs_test.go +260 -0
  33. package/internal/app/github_search_profiles.go +505 -0
  34. package/internal/app/github_search_profiles_test.go +352 -0
  35. package/internal/app/github_test.go +888 -0
  36. package/internal/app/scrape.go +264 -0
  37. package/internal/app/tools.go +670 -0
  38. package/internal/buildinfo/buildinfo.go +16 -0
  39. package/package.json +66 -0
  40. package/scripts/postinstall.js +137 -0
@@ -0,0 +1,484 @@
1
+ package app
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "context"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "net/url"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+ "unicode/utf8"
16
+ )
17
+
18
+ func TestParseGitHubActionsTargets(t *testing.T) {
19
+ tests := []struct {
20
+ raw string
21
+ kind GitHubTargetKind
22
+ name string
23
+ runID int64
24
+ jobID int64
25
+ fragment string
26
+ }{
27
+ {raw: "https://github.com/o/r/actions", kind: GitHubTargetActions},
28
+ {raw: "https://github.com/o/r/actions/workflows", kind: GitHubTargetWorkflows},
29
+ {raw: "https://github.com/o/r/actions/workflows/ci.yml", kind: GitHubTargetWorkflow, name: "ci.yml"},
30
+ {raw: "https://github.com/o/r/actions/runs/123", kind: GitHubTargetActionsRun, runID: 123},
31
+ {raw: "https://github.com/o/r/actions/runs/123/job/456", kind: GitHubTargetActionsJob, runID: 123, jobID: 456},
32
+ {raw: "https://github.com/o/r/actions/runs/123/job/456#step:1:2", kind: GitHubTargetActionsJob, runID: 123, jobID: 456, fragment: "step:1:2"},
33
+ }
34
+ for _, tt := range tests {
35
+ t.Run(tt.raw, func(t *testing.T) {
36
+ target := parseGitHubTarget(tt.raw)
37
+ if target == nil || target.Kind != tt.kind || target.Name != tt.name || target.RunID != tt.runID || target.JobID != tt.jobID || target.Fragment != tt.fragment {
38
+ t.Fatalf("unexpected target: %#v", target)
39
+ }
40
+ })
41
+ }
42
+ for _, raw := range []string{
43
+ "https://github.com/o/r/actions/runs/nope",
44
+ "https://github.com/o/r/actions/runs/123/jobs/456",
45
+ "https://github.com/o/r/actions/runs/123/job/nope",
46
+ "https://github.com/o/r/actions/unknown",
47
+ } {
48
+ if target := parseGitHubTarget(raw); target != nil {
49
+ t.Fatalf("unsupported Actions URL %s parsed as %#v", raw, target)
50
+ }
51
+ }
52
+ }
53
+
54
+ func TestActionsOverviewIsBoundedAndPreservesRunFilters(t *testing.T) {
55
+ var server *httptest.Server
56
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57
+ w.Header().Set("Content-Type", "application/json")
58
+ switch r.URL.Path {
59
+ case "/repos/o/r/actions/workflows":
60
+ if r.URL.Query().Get("page") != "" {
61
+ t.Errorf("Actions run page leaked into workflow sidebar query: %s", r.URL.RawQuery)
62
+ }
63
+ if r.URL.Query().Get("per_page") != "4" {
64
+ t.Errorf("Actions root workflow slice=%s", r.URL.RawQuery)
65
+ }
66
+ _, _ = io.WriteString(w, `{"total_count":1,"workflows":[{"id":10,"name":"CI","path":".github/workflows/ci.yml","state":"active","html_url":"https://github.com/o/r/blob/main/.github/workflows/ci.yml"}]}`)
67
+ case "/repos/o/r/actions/runs":
68
+ for key, want := range map[string]string{"branch": "main", "status": "failure", "page": "2", "per_page": "8"} {
69
+ if got := r.URL.Query().Get(key); got != want {
70
+ t.Errorf("run query %s=%q want %q", key, got, want)
71
+ }
72
+ }
73
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/actions/runs?branch=main&status=failure&per_page=8&page=1>; rel="prev", <%s/repos/o/r/actions/runs?branch=main&status=failure&per_page=8&page=3>; rel="next"`, server.URL, server.URL))
74
+ _, _ = io.WriteString(w, `{"total_count":50,"workflow_runs":[{"id":100,"name":"CI","display_title":"Failing build","status":"completed","conclusion":"failure","event":"push","head_branch":"main","html_url":"https://github.com/o/r/actions/runs/100"}]}`)
75
+ default:
76
+ t.Fatalf("unexpected Actions overview request %s", r.URL.Path)
77
+ }
78
+ }))
79
+ defer server.Close()
80
+ target := parseGitHubTarget("https://github.com/o/r/actions?branch=main&status=failure&page=2")
81
+ out, err := readGitHubActionsOverview(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
82
+ if err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ for _, want := range []string{"workflows_reported: 1", "runs_reported: 50", "runs_provider_more_available: true", "[CI](https://github.com/o/r/actions/workflows/ci.yml)", "source https://github.com/o/r/blob/main/.github/workflows/ci.yml", "[Failing build](https://github.com/o/r/actions/runs/100)", "failure", "Previous", "page=1", "Next", "page=3"} {
86
+ if !strings.Contains(out, want) {
87
+ t.Fatalf("Actions overview missing %q:\n%s", want, out)
88
+ }
89
+ }
90
+ }
91
+
92
+ func TestLargeActionsOverviewAndWorkflowStayWithinSharedBudget(t *testing.T) {
93
+ target := &GitHubTarget{Owner: "o", Repo: "r", Query: url.Values{"page": []string{"2"}}}
94
+ workflows := make([]githubWorkflow, 0, 4)
95
+ runs := make([]githubActionsRun, 0, githubPageableListSize)
96
+ for i := 0; i < githubPageableListSize; i++ {
97
+ if i < 4 {
98
+ workflows = append(workflows, githubWorkflow{
99
+ ID: int64(100 + i), Name: fmt.Sprintf("Workflow %02d %s", i, strings.Repeat("descriptive-name-", 15)),
100
+ Path: fmt.Sprintf(".github/workflows/workflow-%02d.yml", i), State: "active",
101
+ HTMLURL: fmt.Sprintf("https://github.com/o/r/blob/main/.github/workflows/workflow-%02d.yml", i),
102
+ })
103
+ }
104
+ runs = append(runs, githubActionsRun{
105
+ ID: int64(1000 + i), DisplayTitle: fmt.Sprintf("Run %02d %s", i, strings.Repeat("generated-title-", 18)),
106
+ Status: "completed", Conclusion: "success", Event: "push", HeadBranch: "main",
107
+ HTMLURL: fmt.Sprintf("https://github.com/o/r/actions/runs/%d", 1000+i),
108
+ })
109
+ }
110
+ overview := renderGitHubActionsOverview(target, workflows, 30, runs, 300, nil)
111
+ if got := utf8.RuneCountInString(overview); got > githubOverviewRunes {
112
+ t.Fatalf("Actions root exceeded shared target: %d runes\n%s", got, overview)
113
+ }
114
+ for _, want := range []string{"All workflows: https://github.com/o/r/actions/workflows", "workflows_local_omitted: 0", "runs_local_omitted: 0", "workflows_provider_more_available: true", "runs_provider_more_available: true", "https://github.com/o/r/actions/workflows/workflow-00.yml"} {
115
+ if !strings.Contains(overview, want) {
116
+ t.Fatalf("Actions root missing %q:\n%s", want, overview)
117
+ }
118
+ }
119
+
120
+ workflow := renderGitHubWorkflow(target, workflows[0], runs, 300, nil)
121
+ if got := utf8.RuneCountInString(workflow); got > githubOverviewRunes {
122
+ t.Fatalf("workflow run list exceeded shared target: %d runes\n%s", got, workflow)
123
+ }
124
+ if !strings.Contains(workflow, "runs_local_omitted: 0") || !strings.Contains(workflow, "url: \"https://github.com/o/r/actions/workflows/workflow-00.yml\"") || !strings.Contains(workflow, "source_url: \"https://github.com/o/r/blob/main/.github/workflows/workflow-00.yml\"") {
125
+ t.Fatalf("workflow page did not preserve complete provider page/native navigation:\n%s", workflow)
126
+ }
127
+ }
128
+
129
+ func TestActionsOverviewRejectsUnmappedUIQueryFilter(t *testing.T) {
130
+ var calls int32
131
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132
+ atomic.AddInt32(&calls, 1)
133
+ }))
134
+ defer server.Close()
135
+ _, err := readGitHubActionsOverview(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions?query=branch%3Amain"))
136
+ if err == nil || !strings.Contains(err.Error(), "not yet a supported native filter") {
137
+ t.Fatalf("expected truthful unsupported filter error, got %v", err)
138
+ }
139
+ if atomic.LoadInt32(&calls) != 0 {
140
+ t.Fatalf("unsupported UI query made provider calls")
141
+ }
142
+ }
143
+
144
+ func TestWorkflowDetailAndRunsStayBounded(t *testing.T) {
145
+ var server *httptest.Server
146
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147
+ w.Header().Set("Content-Type", "application/json")
148
+ switch r.URL.Path {
149
+ case "/repos/o/r/actions/workflows/ci.yml":
150
+ _, _ = io.WriteString(w, `{"id":10,"name":"CI","path":".github/workflows/ci.yml","state":"active","html_url":"https://github.com/o/r/blob/main/.github/workflows/ci.yml"}`)
151
+ case "/repos/o/r/actions/workflows/ci.yml/runs":
152
+ if r.URL.Query().Get("event") != "push" || r.URL.Query().Get("page") != "2" || r.URL.Query().Get("per_page") != "8" {
153
+ t.Errorf("workflow run filters lost: %s", r.URL.RawQuery)
154
+ }
155
+ _, _ = io.WriteString(w, `{"total_count":1,"workflow_runs":[{"id":100,"display_title":"Push run","status":"completed","conclusion":"success","html_url":"https://github.com/o/r/actions/runs/100"}]}`)
156
+ default:
157
+ t.Fatalf("unexpected workflow request %s", r.URL.Path)
158
+ }
159
+ }))
160
+ defer server.Close()
161
+ out, err := readGitHubWorkflow(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/workflows/ci.yml?event=push&page=2"))
162
+ if err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ if !strings.Contains(out, "workflow_id: 10") || !strings.Contains(out, `url: "https://github.com/o/r/actions/workflows/ci.yml"`) || !strings.Contains(out, `source_url: "https://github.com/o/r/blob/main/.github/workflows/ci.yml"`) || !strings.Contains(out, "runs_local_omitted: 0") || !strings.Contains(out, "[Push run](https://github.com/o/r/actions/runs/100)") || !strings.Contains(out, "success") {
166
+ t.Fatalf("workflow output incorrect:\n%s", out)
167
+ }
168
+ }
169
+
170
+ func TestWorkflowRejectsUnmappedUIQueryFilter(t *testing.T) {
171
+ var calls int32
172
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173
+ atomic.AddInt32(&calls, 1)
174
+ }))
175
+ defer server.Close()
176
+ _, err := readGitHubWorkflow(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/workflows/ci.yml?query=event%3Apush"))
177
+ if err == nil || !strings.Contains(err.Error(), "not yet a supported native filter") {
178
+ t.Fatalf("expected truthful unsupported workflow filter error, got %v", err)
179
+ }
180
+ if atomic.LoadInt32(&calls) != 0 {
181
+ t.Fatalf("unsupported workflow query made provider calls")
182
+ }
183
+ }
184
+
185
+ func TestActionsRunPaginatesJobsButBoundsArtifactsWithoutLogs(t *testing.T) {
186
+ var jobPages, artifactPages int32
187
+ var server *httptest.Server
188
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189
+ w.Header().Set("Content-Type", "application/json")
190
+ switch r.URL.Path {
191
+ case "/repos/o/r/actions/runs/100":
192
+ _, _ = io.WriteString(w, `{"id":100,"name":"CI","display_title":"Build main","run_number":7,"run_attempt":2,"event":"push","status":"completed","conclusion":"failure","head_branch":"main","head_sha":"abc","actor":{"login":"alice"},"run_started_at":"2026-08-01T00:00:00Z","html_url":"https://github.com/o/r/actions/runs/100"}`)
193
+ case "/repos/o/r/actions/runs/100/jobs":
194
+ atomic.AddInt32(&jobPages, 1)
195
+ if r.URL.Query().Get("filter") != "latest" {
196
+ t.Errorf("jobs must use latest attempt filter: %s", r.URL.RawQuery)
197
+ }
198
+ if r.URL.Query().Get("page") == "2" {
199
+ _, _ = io.WriteString(w, `{"total_count":2,"jobs":[{"id":202,"run_id":100,"name":"test","status":"completed","conclusion":"failure","html_url":"https://github.com/o/r/actions/runs/100/job/202"}]}`)
200
+ return
201
+ }
202
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/actions/runs/100/jobs?filter=latest&per_page=100&page=2>; rel="next"`, server.URL))
203
+ _, _ = io.WriteString(w, `{"total_count":2,"jobs":[{"id":201,"run_id":100,"name":"build","status":"completed","conclusion":"success","html_url":"https://github.com/o/r/actions/runs/100/job/201"}]}`)
204
+ case "/repos/o/r/actions/runs/100/artifacts":
205
+ atomic.AddInt32(&artifactPages, 1)
206
+ if r.URL.Query().Get("page") == "2" {
207
+ t.Fatal("Actions run overview followed artifact pagination")
208
+ }
209
+ w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/actions/runs/100/artifacts?per_page=100&page=2>; rel="next"`, server.URL))
210
+ _, _ = io.WriteString(w, `{"total_count":2,"artifacts":[{"id":301,"name":"report","size_in_bytes":10,"expired":false,"expires_at":"2026-09-01T00:00:00Z","archive_download_url":"https://api.github.test/artifacts/301/zip"}]}`)
211
+ case "/repos/o/r/actions/jobs/201/logs", "/repos/o/r/actions/jobs/202/logs":
212
+ t.Fatal("run page must not fetch job logs")
213
+ default:
214
+ t.Fatalf("unexpected run request %s", r.URL.Path)
215
+ }
216
+ }))
217
+ defer server.Close()
218
+ out, err := readGitHubActionsRun(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100"))
219
+ if err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ for _, want := range []string{
223
+ "run_id: 100", "run_number: 7", "attempt: 2", "conclusion: \"failure\"",
224
+ "jobs_returned: 2", "jobs_reported: 2", "job_conclusion_counts: {\"failure\":1,\"success\":1}",
225
+ "[test](https://github.com/o/r/actions/runs/100/job/202)", "[build](https://github.com/o/r/actions/runs/100/job/201)",
226
+ "artifacts_returned: 1", "artifacts_reported: 2", "artifacts_provider_more_available: true",
227
+ "**report** — id 301 · 10 bytes", "expires 2026-09-01T00:00:00Z", "archive API: https://api.github.test/artifacts/301/zip",
228
+ "GitHub has more artifacts beyond the provider page fetched for this overview",
229
+ } {
230
+ if !strings.Contains(out, want) {
231
+ t.Fatalf("run output missing %q:\n%s", want, out)
232
+ }
233
+ }
234
+ if strings.Index(out, "[test]") > strings.Index(out, "[build]") {
235
+ t.Fatalf("failed job was not prioritized ahead of success:\n%s", out)
236
+ }
237
+ if strings.Contains(out, "expired-report") {
238
+ t.Fatalf("run overview rendered unfetched artifact page:\n%s", out)
239
+ }
240
+ if atomic.LoadInt32(&jobPages) != 2 || atomic.LoadInt32(&artifactPages) != 1 {
241
+ t.Fatalf("run pagination incomplete jobs=%d artifacts=%d", jobPages, artifactPages)
242
+ }
243
+ }
244
+
245
+ func TestLargeActionsRunPrioritizesJobsAndBoundsArtifacts(t *testing.T) {
246
+ jobs := make([]githubActionsJob, 0, 130)
247
+ for i := 0; i < 128; i++ {
248
+ jobs = append(jobs, githubActionsJob{
249
+ ID: int64(i + 1), RunID: 100, Name: fmt.Sprintf("success-%03d-%s", i, strings.Repeat("routine-", 10)),
250
+ Status: "completed", Conclusion: "success", HTMLURL: fmt.Sprintf("https://github.com/o/r/actions/runs/100/job/%d", i+1),
251
+ })
252
+ }
253
+ jobs = append(jobs,
254
+ githubActionsJob{ID: 9002, RunID: 100, Name: "yyy-active", Status: "in_progress", HTMLURL: "https://github.com/o/r/actions/runs/100/job/9002"},
255
+ githubActionsJob{ID: 9001, RunID: 100, Name: "zzz-hard-failure", Status: "completed", Conclusion: "failure", HTMLURL: "https://github.com/o/r/actions/runs/100/job/9001"},
256
+ )
257
+ artifacts := make([]githubActionsArtifact, 0, 100)
258
+ for i := 0; i < 100; i++ {
259
+ artifacts = append(artifacts, githubActionsArtifact{
260
+ ID: int64(5000 + i), Name: fmt.Sprintf("artifact-%03d-%s", i, strings.Repeat("generated-", 8)), SizeInBytes: int64(100 + i),
261
+ ExpiresAt: "2026-09-01T00:00:00Z", ArchiveDownloadURL: fmt.Sprintf("https://api.github.com/repos/o/r/actions/artifacts/%d/zip", 5000+i),
262
+ })
263
+ }
264
+ artifacts[0].Expired = true
265
+ out := renderGitHubActionsRun(&GitHubTarget{Owner: "o", Repo: "r", RunID: 100}, githubActionsRun{
266
+ ID: 100, RunNumber: 7, RunAttempt: 1, Status: "completed", Conclusion: "failure", DisplayTitle: "Large run",
267
+ }, jobs, artifacts, githubActionsRunAvailability{JobsReported: 130, ArtifactsReported: 133, ArtifactsProviderMore: true})
268
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
269
+ t.Fatalf("large Actions run exceeded shared target: %d runes\n%s", got, out)
270
+ }
271
+ for _, want := range []string{
272
+ "jobs_returned: 130", `job_status_counts: {"completed":129,"in_progress":1}`, `job_conclusion_counts: {"failure":1,"none":1,"success":128}`,
273
+ "jobs_local_omitted:", "zzz-hard-failure", "yyy-active", "success-000-", "id 9001", "id 9002",
274
+ "artifacts_returned: 100", "artifacts_reported: 133", "artifacts_provider_more_available: true", "artifacts_local_omitted:", "archive API:",
275
+ "locally omitted from this overview", "GitHub has more artifacts beyond the provider page fetched for this overview",
276
+ } {
277
+ if !strings.Contains(out, want) {
278
+ t.Fatalf("large Actions run missing %q:\n%s", want, out)
279
+ }
280
+ }
281
+ failureAt := strings.Index(out, "zzz-hard-failure")
282
+ activeAt := strings.Index(out, "yyy-active")
283
+ successAt := strings.Index(out, "success-000-")
284
+ if failureAt < 0 || activeAt < 0 || successAt < 0 || !(failureAt < activeAt && activeAt < successAt) {
285
+ t.Fatalf("job priority is not failure -> active -> routine success:\n%s", out)
286
+ }
287
+ }
288
+
289
+ func TestActionsJobFetchesOnlySelectedJobAndPlainLog(t *testing.T) {
290
+ var requests int32
291
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
292
+ atomic.AddInt32(&requests, 1)
293
+ switch r.URL.Path {
294
+ case "/repos/o/r/actions/jobs/202":
295
+ w.Header().Set("Content-Type", "application/json")
296
+ _, _ = io.WriteString(w, `{"id":202,"run_id":100,"run_attempt":2,"name":"test","status":"completed","conclusion":"failure","head_sha":"abc","html_url":"https://github.com/o/r/actions/runs/100/job/202","steps":[{"number":1,"name":"Checkout","status":"completed","conclusion":"success"},{"number":2,"name":"Test","status":"completed","conclusion":"failure"}]}`)
297
+ case "/repos/o/r/actions/jobs/202/logs":
298
+ w.Header().Set("Content-Type", "text/plain")
299
+ _, _ = io.WriteString(w, "line one\nline two\n")
300
+ default:
301
+ t.Fatalf("selected job fetched unrelated endpoint %s", r.URL.Path)
302
+ }
303
+ }))
304
+ defer server.Close()
305
+ out, err := readGitHubActionsJob(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100/job/202"))
306
+ if err != nil {
307
+ t.Fatal(err)
308
+ }
309
+ if atomic.LoadInt32(&requests) != 2 || !strings.Contains(out, "job_id: 202") || !strings.Contains(out, "1. **Checkout** — completed · success") || !strings.Contains(out, "2. **Test** — completed · failure") || !strings.Contains(out, "line one\nline two") || !strings.Contains(out, "log_preview_strategy: \"full\"") || !strings.Contains(out, "Stable job log endpoint: https://api.github.com/repos/o/r/actions/jobs/202/logs") || strings.Contains(out, "job/201") {
310
+ t.Fatalf("job output/scoping incorrect:\n%s", out)
311
+ }
312
+ }
313
+
314
+ func TestActionsJobLargeFailureLogIsBoundedAroundFailedStepAndRawNavigation(t *testing.T) {
315
+ logLines := make([]string, 0, 2400)
316
+ for i := 0; i < 1700; i++ {
317
+ logLines = append(logLines, fmt.Sprintf("setup line %04d %s", i, strings.Repeat("routine ", 8)))
318
+ }
319
+ logLines = append(logLines, "2026-08-14T00:00:00Z Integration tests", "2026-08-14T00:00:01Z ##[error]expected 200 got 500", "2026-08-14T00:00:02Z Process completed with exit code 1")
320
+ for i := 0; i < 650; i++ {
321
+ logLines = append(logLines, fmt.Sprintf("cleanup line %04d %s", i, strings.Repeat("routine ", 8)))
322
+ }
323
+ logLines = append(logLines, "TERMINAL TAIL MARKER")
324
+ largeLog := strings.Join(logLines, "\n") + "\n"
325
+ var requests int32
326
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
327
+ atomic.AddInt32(&requests, 1)
328
+ switch r.URL.Path {
329
+ case "/repos/o/r/actions/jobs/202":
330
+ w.Header().Set("Content-Type", "application/json")
331
+ _, _ = io.WriteString(w, `{"id":202,"run_id":100,"name":"integration","status":"completed","conclusion":"failure","html_url":"https://github.com/o/r/actions/runs/100/job/202","steps":[{"number":1,"name":"Checkout","status":"completed","conclusion":"success"},{"number":2,"name":"Integration tests","status":"completed","conclusion":"failure"}]}`)
332
+ case "/repos/o/r/actions/jobs/202/logs":
333
+ w.Header().Set("Content-Type", "text/plain")
334
+ _, _ = io.WriteString(w, largeLog)
335
+ default:
336
+ t.Fatalf("large selected job fetched unrelated endpoint %s", r.URL.Path)
337
+ }
338
+ }))
339
+ defer server.Close()
340
+ out, err := readGitHubActionsJob(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100/job/202"))
341
+ if err != nil {
342
+ t.Fatal(err)
343
+ }
344
+ if got := utf8.RuneCountInString(out); got > githubOverviewRunes {
345
+ t.Fatalf("large selected job exceeded shared target: %d runes\n%s", got, out)
346
+ }
347
+ for _, want := range []string{
348
+ "2. **Integration tests** — completed · failure", `log_preview_strategy: "failed-step-context+tail"`, "log_preview_truncated: true",
349
+ "Integration tests", "##[error]expected 200 got 500", "Process completed with exit code 1", "TERMINAL TAIL MARKER",
350
+ "Job log preview locally truncated", "Stable job log endpoint: https://api.github.com/repos/o/r/actions/jobs/202/logs", "expires after one minute", "signed log download", "does not print that redirect location",
351
+ } {
352
+ if !strings.Contains(out, want) {
353
+ t.Fatalf("large selected job missing %q:\n%s", want, out)
354
+ }
355
+ }
356
+ if strings.Contains(out, "setup line 0000") || strings.Contains(out, "setup line 1000") || atomic.LoadInt32(&requests) != 2 {
357
+ t.Fatalf("large selected job did not stay scoped/bounded (requests=%d):\n%s", requests, out)
358
+ }
359
+ }
360
+
361
+ func TestSuccessfulLargeJobLogUsesDeterministicHeadTailPreview(t *testing.T) {
362
+ lines := []string{"HEAD LOG MARKER"}
363
+ for i := 0; i < 300; i++ {
364
+ lines = append(lines, fmt.Sprintf("routine line %03d %s", i, strings.Repeat("data ", 10)))
365
+ }
366
+ lines = append(lines, "TAIL LOG MARKER")
367
+ preview := buildGitHubJobLogPreview(strings.Join(lines, "\n"), githubActionsJob{Conclusion: "success"}, 1200)
368
+ if preview.Strategy != "head+tail" || !preview.Truncated || !strings.Contains(preview.Text, "HEAD LOG MARKER") || !strings.Contains(preview.Text, "TAIL LOG MARKER") || !strings.Contains(preview.Text, "log lines omitted") {
369
+ t.Fatalf("unexpected successful-log head/tail preview: %#v\n%s", preview, preview.Text)
370
+ }
371
+ if utf8.RuneCountInString(preview.Text) > 1200 {
372
+ t.Fatalf("head/tail preview exceeded requested budget: %d", utf8.RuneCountInString(preview.Text))
373
+ }
374
+ }
375
+
376
+ func TestActionsJobRejectsMismatchedRunAndUnprovenStepFragment(t *testing.T) {
377
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
378
+ w.Header().Set("Content-Type", "application/json")
379
+ _, _ = io.WriteString(w, `{"id":202,"run_id":999,"name":"wrong"}`)
380
+ }))
381
+ defer server.Close()
382
+ _, err := readGitHubActionsJob(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100/job/202"))
383
+ if err == nil || !strings.Contains(err.Error(), "belongs to run 999") {
384
+ t.Fatalf("expected run ownership error, got %v", err)
385
+ }
386
+ _, err = readGitHubActionsJob(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100/job/202#step:1:2"))
387
+ if err == nil || !strings.Contains(err.Error(), "not a proven native selector") {
388
+ t.Fatalf("unproven step selector should be rejected, got %v", err)
389
+ }
390
+ }
391
+
392
+ func TestActionsJobLogUnavailableIsTruthful(t *testing.T) {
393
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394
+ switch r.URL.Path {
395
+ case "/repos/o/r/actions/jobs/202":
396
+ w.Header().Set("Content-Type", "application/json")
397
+ _, _ = io.WriteString(w, `{"id":202,"run_id":100,"name":"old","status":"completed","conclusion":"success"}`)
398
+ case "/repos/o/r/actions/jobs/202/logs":
399
+ w.WriteHeader(http.StatusGone)
400
+ _, _ = io.WriteString(w, `{"message":"Gone"}`)
401
+ }
402
+ }))
403
+ defer server.Close()
404
+ out, err := readGitHubActionsJob(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/o/r/actions/runs/100/job/202"))
405
+ if err != nil {
406
+ t.Fatal(err)
407
+ }
408
+ if !strings.Contains(out, "HTTP 410") || !strings.Contains(out, "expired, deleted, or not yet generated") {
409
+ t.Fatalf("unavailable log state missing:\n%s", out)
410
+ }
411
+ }
412
+
413
+ func TestDecodeGitHubJobLogZIPAndMalformedArchive(t *testing.T) {
414
+ var buf bytes.Buffer
415
+ zw := zip.NewWriter(&buf)
416
+ for _, item := range []struct{ name, body string }{{"2_test.txt", "test log\n"}, {"1_build.txt", "build log\n"}} {
417
+ w, err := zw.Create(item.name)
418
+ if err != nil {
419
+ t.Fatal(err)
420
+ }
421
+ _, _ = io.WriteString(w, item.body)
422
+ }
423
+ if err := zw.Close(); err != nil {
424
+ t.Fatal(err)
425
+ }
426
+ text, err := decodeGitHubJobLog(buf.Bytes())
427
+ if err != nil {
428
+ t.Fatal(err)
429
+ }
430
+ if strings.Index(text, "1_build.txt") > strings.Index(text, "2_test.txt") || !strings.Contains(text, "build log") || !strings.Contains(text, "test log") {
431
+ t.Fatalf("zip job log order/content incorrect:\n%s", text)
432
+ }
433
+ if _, err := decodeGitHubJobLog([]byte{'P', 'K', 3, 4, 0, 1, 2}); err == nil || !strings.Contains(err.Error(), "decode GitHub Actions job log archive") {
434
+ t.Fatalf("malformed zip should fail truthfully, got %v", err)
435
+ }
436
+ }
437
+
438
+ func TestActionsJobLogRedirectDoesNotForwardTokenToStorageHost(t *testing.T) {
439
+ var storageAuth string
440
+ storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
441
+ storageAuth = r.Header.Get("Authorization")
442
+ _, _ = io.WriteString(w, "redirected log\n")
443
+ }))
444
+ defer storage.Close()
445
+ storageURL := strings.Replace(storage.URL, "127.0.0.1", "localhost", 1)
446
+
447
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
448
+ if r.Header.Get("Authorization") != "Bearer fake-token" {
449
+ t.Errorf("API request missing configured auth")
450
+ }
451
+ http.Redirect(w, r, storageURL, http.StatusFound)
452
+ }))
453
+ defer api.Close()
454
+
455
+ log, err := fetchGitHubActionsJobLog(context.Background(), testGitHubClient(api.URL, api.URL, "fake-token"), &GitHubTarget{Owner: "o", Repo: "r", JobID: 202})
456
+ if err != nil {
457
+ t.Fatal(err)
458
+ }
459
+ if log.Text != "redirected log\n" {
460
+ t.Fatalf("redirected log body mismatch: %q", log.Text)
461
+ }
462
+ if storageAuth != "" {
463
+ t.Fatalf("GitHub token leaked to redirected storage host: %q", storageAuth)
464
+ }
465
+ }
466
+
467
+ func TestActionsProviderFailuresStayNative(t *testing.T) {
468
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
469
+ w.WriteHeader(http.StatusNotFound)
470
+ _, _ = io.WriteString(w, `{"message":"private actions detail"}`)
471
+ }))
472
+ defer server.Close()
473
+ for _, raw := range []string{
474
+ "https://github.com/o/r/actions",
475
+ "https://github.com/o/r/actions/workflows/ci.yml",
476
+ "https://github.com/o/r/actions/runs/100",
477
+ "https://github.com/o/r/actions/runs/100/job/202",
478
+ } {
479
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget(raw), testGitHubClient(server.URL, server.URL, ""))
480
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || strings.Contains(result.Err.Error(), "private actions detail") {
481
+ t.Fatalf("native Actions failure regressed for %s: %#v", raw, result)
482
+ }
483
+ }
484
+ }