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.
- package/AGENTS.md +97 -0
- package/CONTRIBUTORS.md +126 -0
- package/LICENSE +202 -0
- package/Makefile +69 -0
- package/README.md +121 -0
- package/bin/webctx.js +28 -0
- package/cmd/webctx/main.go +11 -0
- package/docs/porting-status.md +173 -0
- package/go.mod +3 -0
- package/internal/app/app.go +139 -0
- package/internal/app/app_test.go +194 -0
- package/internal/app/github.go +2084 -0
- package/internal/app/github_acceptance_test.go +166 -0
- package/internal/app/github_actions.go +1008 -0
- package/internal/app/github_actions_test.go +484 -0
- package/internal/app/github_activity_deployments.go +503 -0
- package/internal/app/github_activity_deployments_test.go +321 -0
- package/internal/app/github_commits.go +1004 -0
- package/internal/app/github_commits_test.go +503 -0
- package/internal/app/github_discussions_gists.go +1156 -0
- package/internal/app/github_discussions_gists_test.go +425 -0
- package/internal/app/github_issues.go +1616 -0
- package/internal/app/github_issues_test.go +731 -0
- package/internal/app/github_packages_projects.go +429 -0
- package/internal/app/github_packages_projects_test.go +168 -0
- package/internal/app/github_phase9_overview_stress_test.go +178 -0
- package/internal/app/github_pull_views.go +1141 -0
- package/internal/app/github_pull_views_test.go +603 -0
- package/internal/app/github_pulls.go +1459 -0
- package/internal/app/github_pulls_test.go +715 -0
- package/internal/app/github_refs.go +621 -0
- package/internal/app/github_refs_test.go +260 -0
- package/internal/app/github_search_profiles.go +505 -0
- package/internal/app/github_search_profiles_test.go +352 -0
- package/internal/app/github_test.go +888 -0
- package/internal/app/scrape.go +264 -0
- package/internal/app/tools.go +670 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/package.json +66 -0
- 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
|
+
}
|