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,352 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "strings"
11
+ "sync/atomic"
12
+ "testing"
13
+ )
14
+
15
+ func TestParseGitHubSearchAndProfileTargets(t *testing.T) {
16
+ for _, tt := range []struct {
17
+ raw string
18
+ kind GitHubTargetKind
19
+ owner string
20
+ repo string
21
+ name string
22
+ }{
23
+ {raw: "https://github.com/search?q=webctx&type=repositories", kind: GitHubTargetSearch},
24
+ {raw: "https://github.com/o/r/search?q=reader&type=code", kind: GitHubTargetSearch, owner: "o", repo: "r"},
25
+ {raw: "https://github.com/octocat", kind: GitHubTargetProfile, owner: "octocat"},
26
+ {raw: "https://github.com/openai?tab=repositories", kind: GitHubTargetProfile, owner: "openai"},
27
+ {raw: "https://github.com/orgs/openai/people", kind: GitHubTargetProfile, owner: "openai", name: "people"},
28
+ } {
29
+ target := parseGitHubTarget(tt.raw)
30
+ if target == nil || target.Kind != tt.kind || target.Owner != tt.owner || target.Repo != tt.repo || target.Name != tt.name {
31
+ t.Fatalf("target %s => %#v", tt.raw, target)
32
+ }
33
+ }
34
+ for _, raw := range []string{
35
+ "https://github.com/search",
36
+ "https://github.com/search?q=x&type=unknown",
37
+ "https://github.com/search?q=x&type=repositories&unknown=y",
38
+ "https://github.com/settings",
39
+ "https://github.com/login",
40
+ "https://github.com/marketplace",
41
+ } {
42
+ if target := parseGitHubTarget(raw); target != nil {
43
+ t.Fatalf("unsupported/global route was claimed: %s => %#v", raw, target)
44
+ }
45
+ }
46
+ }
47
+
48
+ func TestGitHubSearchRepositoryScopeSortOrderAndNavigation(t *testing.T) {
49
+ var server *httptest.Server
50
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
51
+ if r.URL.Path != "/search/repositories" {
52
+ t.Fatalf("unexpected search path %s", r.URL.Path)
53
+ }
54
+ for key, want := range map[string]string{"q": "reader repo:o/r", "sort": "stars", "order": "desc", "page": "2", "per_page": "8"} {
55
+ if got := r.URL.Query().Get(key); got != want {
56
+ t.Errorf("search query %s=%q want %q (%s)", key, got, want, r.URL.RawQuery)
57
+ }
58
+ }
59
+ w.Header().Set("Content-Type", "application/json")
60
+ w.Header().Set("Link", fmt.Sprintf(`<%s/search/repositories?q=reader+repo%%3Ao%%2Fr&sort=stars&order=desc&per_page=8&page=1>; rel="prev", <%s/search/repositories?q=reader+repo%%3Ao%%2Fr&sort=stars&order=desc&per_page=8&page=3>; rel="next"`, server.URL, server.URL))
61
+ _, _ = io.WriteString(w, `{"total_count":1200,"incomplete_results":true,"items":[{"full_name":"o/r","html_url":"https://github.com/o/r","description":"repo","language":"Go","stargazers_count":12,"forks_count":3,"archived":false}]}`)
62
+ }))
63
+ defer server.Close()
64
+ target := parseGitHubTarget("https://github.com/o/r/search?q=reader&type=repositories&s=stars&o=desc&p=2")
65
+ out, err := readGitHubSearch(context.Background(), testGitHubClient(server.URL, server.URL, ""), target)
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ for _, want := range []string{
70
+ `repository_scope: "o/r"`, `total_count: 1200`, `incomplete_results: true`, `provider_result_ceiling: 1000`,
71
+ "[o/r](https://github.com/o/r)", "12 stars", "GitHub marked these search results incomplete", "at most 1000 results",
72
+ "Previous: https://github.com/o/r/search?", "p=1", "Next: https://github.com/o/r/search?", "p=3",
73
+ } {
74
+ if !strings.Contains(out, want) {
75
+ t.Fatalf("search output missing %q:\n%s", want, out)
76
+ }
77
+ }
78
+ }
79
+
80
+ func TestGitHubSearchIssueAndPRQualifiers(t *testing.T) {
81
+ for _, tt := range []struct {
82
+ typeValue string
83
+ want string
84
+ notWant string
85
+ }{
86
+ {typeValue: "issues", want: "is:issue", notWant: "is:pr"},
87
+ {typeValue: "pullrequests", want: "is:pr", notWant: "is:issue"},
88
+ } {
89
+ t.Run(tt.typeValue, func(t *testing.T) {
90
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91
+ if r.URL.Path != "/search/issues" {
92
+ t.Fatalf("issue/PR search endpoint=%s", r.URL.Path)
93
+ }
94
+ q := r.URL.Query().Get("q")
95
+ if !strings.Contains(q, tt.want) || strings.Contains(q, tt.notWant) {
96
+ t.Fatalf("provider query=%q want %q without %q", q, tt.want, tt.notWant)
97
+ }
98
+ w.Header().Set("Content-Type", "application/json")
99
+ _, _ = io.WriteString(w, `{"total_count":1,"incomplete_results":false,"items":[{"number":7,"title":"Result","state":"open","html_url":"https://github.com/o/r/issues/7","user":{"login":"alice"},"updated_at":"2026-08-01T00:00:00Z"}]}`)
100
+ }))
101
+ defer server.Close()
102
+ out, err := readGitHubSearch(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/search?q=bug&type="+tt.typeValue))
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ if !strings.Contains(out, "[#7 Result]") || !strings.Contains(out, "@alice") {
107
+ t.Fatalf("search result missing:\n%s", out)
108
+ }
109
+ })
110
+ }
111
+ }
112
+
113
+ func TestGitHubSearchCompactResultTypes(t *testing.T) {
114
+ tests := []struct {
115
+ typeValue string
116
+ item string
117
+ want []string
118
+ }{
119
+ {typeValue: "code", item: `{"name":"x.go","path":"internal/x.go","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","html_url":"https://github.com/o/r/blob/a/internal/x.go","repository":{"full_name":"o/r"}}`, want: []string{"internal/x.go", "o/r", "`aaaaaaaaaaaa`"}},
120
+ {typeValue: "commits", item: `{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","html_url":"https://github.com/o/r/commit/b","commit":{"message":"Subject\nBody","author":{"date":"2026-08-01T00:00:00Z"}},"repository":{"full_name":"o/r"}}`, want: []string{"`bbbbbbbbbbbb`", "Subject", "o/r"}},
121
+ {typeValue: "users", item: `{"login":"alice","type":"User","html_url":"https://github.com/alice"}`, want: []string{"@alice", "User"}},
122
+ }
123
+ for _, tt := range tests {
124
+ t.Run(tt.typeValue, func(t *testing.T) {
125
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126
+ w.Header().Set("Content-Type", "application/json")
127
+ _, _ = io.WriteString(w, `{"total_count":1,"incomplete_results":false,"items":[`+tt.item+`]}`)
128
+ }))
129
+ defer server.Close()
130
+ out, err := readGitHubSearch(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/search?q=x&type="+tt.typeValue))
131
+ if err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ for _, want := range tt.want {
135
+ if !strings.Contains(out, want) {
136
+ t.Fatalf("%s search missing %q:\n%s", tt.typeValue, want, out)
137
+ }
138
+ }
139
+ })
140
+ }
141
+ }
142
+
143
+ func TestGitHubSearchRateLimitUsesSearchResource(t *testing.T) {
144
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145
+ w.Header().Set("X-RateLimit-Remaining", "0")
146
+ w.Header().Set("X-RateLimit-Resource", "search")
147
+ w.Header().Set("X-RateLimit-Reset", "1893456000")
148
+ w.WriteHeader(http.StatusForbidden)
149
+ _, _ = io.WriteString(w, `{"message":"provider body must not leak"}`)
150
+ }))
151
+ defer server.Close()
152
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/search?q=x&type=repositories"), testGitHubClient(server.URL, server.URL, ""))
153
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || !strings.Contains(strings.ToLower(result.Err.Error()), "search") || strings.Contains(result.Err.Error(), "provider body") {
154
+ t.Fatalf("Search rate-limit resource/error wrong: %#v", result)
155
+ }
156
+ }
157
+
158
+ func TestUserProfileResolvesProviderTypeWithoutOrgProbe(t *testing.T) {
159
+ var calls int32
160
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161
+ atomic.AddInt32(&calls, 1)
162
+ if r.URL.Path != "/users/alice" {
163
+ t.Fatalf("User profile made guessed/org request: %s", r.URL.Path)
164
+ }
165
+ w.Header().Set("Content-Type", "application/json")
166
+ _, _ = io.WriteString(w, `{"login":"alice","type":"User","name":"Alice","bio":"Builder","public_repos":12,"public_gists":3,"followers":4,"following":5,"html_url":"https://github.com/alice","created_at":"2020-01-01T00:00:00Z"}`)
167
+ }))
168
+ defer server.Close()
169
+ out, err := readGitHubProfile(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/alice"))
170
+ if err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ for _, want := range []string{`login: "alice"`, `type: "User"`, "public_repositories: 12", "public_gists: 3", "followers: 4", "# alice", "Builder", "?tab=repositories", "?tab=stars"} {
174
+ if !strings.Contains(out, want) {
175
+ t.Fatalf("User profile missing %q:\n%s", want, out)
176
+ }
177
+ }
178
+ if atomic.LoadInt32(&calls) != 1 {
179
+ t.Fatalf("User profile should require one type-resolution call, got %d", calls)
180
+ }
181
+ }
182
+
183
+ func TestOrganizationProfileUsesProviderTypeThenOrgEndpoint(t *testing.T) {
184
+ var sequence []string
185
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186
+ sequence = append(sequence, r.URL.Path)
187
+ w.Header().Set("Content-Type", "application/json")
188
+ switch r.URL.Path {
189
+ case "/users/acme":
190
+ _, _ = io.WriteString(w, `{"login":"acme","type":"Organization","html_url":"https://github.com/acme"}`)
191
+ case "/orgs/acme":
192
+ _, _ = io.WriteString(w, `{"login":"acme","name":"Acme","description":"ignored","blog":"https://acme.test","location":"Earth","public_repos":42,"html_url":"https://github.com/acme","created_at":"2019-01-01T00:00:00Z"}`)
193
+ default:
194
+ t.Fatalf("unexpected org profile request %s", r.URL.Path)
195
+ }
196
+ }))
197
+ defer server.Close()
198
+ out, err := readGitHubProfile(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/acme"))
199
+ if err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ if strings.Join(sequence, ",") != "/users/acme,/orgs/acme" || !strings.Contains(out, `type: "Organization"`) || !strings.Contains(out, "public_repositories: 42") || !strings.Contains(out, "/orgs/acme/people") {
203
+ t.Fatalf("provider-resolved Organization profile wrong requests=%v:\n%s", sequence, out)
204
+ }
205
+ }
206
+
207
+ func TestUserProfileTabsAreBoundedToSelectedEndpoint(t *testing.T) {
208
+ for _, tt := range []struct {
209
+ tab string
210
+ endpoint string
211
+ body string
212
+ want string
213
+ }{
214
+ {tab: "repositories", endpoint: "/users/alice/repos", body: `[{"full_name":"alice/r","html_url":"https://github.com/alice/r","stargazers_count":2}]`, want: "[alice/r]"},
215
+ {tab: "stars", endpoint: "/users/alice/starred", body: `[{"full_name":"o/r","html_url":"https://github.com/o/r","stargazers_count":9}]`, want: "[o/r]"},
216
+ {tab: "gists", endpoint: "/users/alice/gists", body: `[{"id":"abc","html_url":"https://gist.github.com/alice/abc","description":"demo","files":{"a.txt":{"filename":"a.txt"}}}]`, want: "[abc]"},
217
+ {tab: "followers", endpoint: "/users/alice/followers", body: `[{"login":"bob","type":"User","html_url":"https://github.com/bob"}]`, want: "@bob"},
218
+ {tab: "following", endpoint: "/users/alice/following", body: `[{"login":"carol","type":"User","html_url":"https://github.com/carol"}]`, want: "@carol"},
219
+ } {
220
+ t.Run(tt.tab, func(t *testing.T) {
221
+ var listCalls int32
222
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
223
+ w.Header().Set("Content-Type", "application/json")
224
+ if r.URL.Path == "/users/alice" {
225
+ _, _ = io.WriteString(w, `{"login":"alice","type":"User"}`)
226
+ return
227
+ }
228
+ atomic.AddInt32(&listCalls, 1)
229
+ if r.URL.Path != tt.endpoint || r.URL.Query().Get("per_page") != "8" || r.URL.Query().Get("page") != "2" {
230
+ t.Fatalf("tab %s request %s?%s", tt.tab, r.URL.Path, r.URL.RawQuery)
231
+ }
232
+ _, _ = io.WriteString(w, tt.body)
233
+ }))
234
+ defer server.Close()
235
+ out, err := readGitHubProfile(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/alice?tab="+tt.tab+"&page=2"))
236
+ if err != nil {
237
+ t.Fatal(err)
238
+ }
239
+ if atomic.LoadInt32(&listCalls) != 1 || !strings.Contains(out, `view: "`+tt.tab+`"`) || !strings.Contains(out, tt.want) {
240
+ t.Fatalf("tab output wrong:\n%s", out)
241
+ }
242
+ })
243
+ }
244
+ }
245
+
246
+ func TestOrganizationRepositoriesAndPublicPeople(t *testing.T) {
247
+ for _, tt := range []struct {
248
+ raw string
249
+ endpoint string
250
+ want string
251
+ }{
252
+ {raw: "https://github.com/acme?tab=repositories", endpoint: "/orgs/acme/repos", want: "[acme/r]"},
253
+ {raw: "https://github.com/orgs/acme/people", endpoint: "/orgs/acme/public_members", want: "@member"},
254
+ } {
255
+ t.Run(tt.endpoint, func(t *testing.T) {
256
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257
+ w.Header().Set("Content-Type", "application/json")
258
+ switch r.URL.Path {
259
+ case "/users/acme":
260
+ _, _ = io.WriteString(w, `{"login":"acme","type":"Organization"}`)
261
+ case "/orgs/acme":
262
+ _, _ = io.WriteString(w, `{"login":"acme"}`)
263
+ case tt.endpoint:
264
+ if strings.HasSuffix(tt.endpoint, "/repos") {
265
+ _, _ = io.WriteString(w, `[{"full_name":"acme/r","html_url":"https://github.com/acme/r","stargazers_count":1}]`)
266
+ } else {
267
+ _, _ = io.WriteString(w, `[{"login":"member","type":"User","html_url":"https://github.com/member"}]`)
268
+ }
269
+ default:
270
+ t.Fatalf("unexpected org tab request %s", r.URL.Path)
271
+ }
272
+ }))
273
+ defer server.Close()
274
+ out, err := readGitHubProfile(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget(tt.raw))
275
+ if err != nil {
276
+ t.Fatal(err)
277
+ }
278
+ if !strings.Contains(out, tt.want) {
279
+ t.Fatalf("org tab output missing %q:\n%s", tt.want, out)
280
+ }
281
+ })
282
+ }
283
+ }
284
+
285
+ func TestInvalidProfileTabFailsAfterTypeResolutionBeforeListRead(t *testing.T) {
286
+ var listCalls int32
287
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
288
+ w.Header().Set("Content-Type", "application/json")
289
+ if r.URL.Path == "/users/alice" {
290
+ _, _ = io.WriteString(w, `{"login":"alice","type":"User"}`)
291
+ return
292
+ }
293
+ atomic.AddInt32(&listCalls, 1)
294
+ }))
295
+ defer server.Close()
296
+ _, err := readGitHubProfile(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/alice?tab=people"))
297
+ if err == nil || !strings.Contains(err.Error(), "User profile tab") || atomic.LoadInt32(&listCalls) != 0 {
298
+ t.Fatalf("invalid User tab handling err=%v listCalls=%d", err, listCalls)
299
+ }
300
+ }
301
+
302
+ func TestProfileProvider404StaysNative(t *testing.T) {
303
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304
+ w.WriteHeader(http.StatusNotFound)
305
+ _, _ = io.WriteString(w, `{"message":"private provider body"}`)
306
+ }))
307
+ defer server.Close()
308
+ result := readGitHubNativeWithClient(context.Background(), parseGitHubTarget("https://github.com/nobody"), testGitHubClient(server.URL, server.URL, ""))
309
+ if result.Outcome != GitHubNativeFailure || result.Err == nil || strings.Contains(result.Err.Error(), "private provider body") {
310
+ t.Fatalf("profile native failure boundary regressed: %#v", result)
311
+ }
312
+ }
313
+
314
+ func TestSearchNavigationKeepsCopiedUIQuery(t *testing.T) {
315
+ target := parseGitHubTarget("https://github.com/search?q=go+cli&type=repositories&s=stars&o=desc&p=2")
316
+ links := GitHubLinkRelations{
317
+ "prev": "https://api.github.com/search/repositories?q=go+cli&page=1",
318
+ "next": "https://api.github.com/search/repositories?q=go+cli&page=3",
319
+ }
320
+ lines := strings.Join(renderGitHubSearchNavigation(target, links), "\n")
321
+ for _, want := range []string{"q=go+cli", "type=repositories", "s=stars", "o=desc", "p=1", "p=3"} {
322
+ if !strings.Contains(lines, want) {
323
+ t.Fatalf("search navigation lost %q:\n%s", want, lines)
324
+ }
325
+ }
326
+ }
327
+
328
+ func TestSupportedSearchURLQueryValidation(t *testing.T) {
329
+ valid := url.Values{"q": []string{"x"}, "type": []string{"repositories"}, "p": []string{"2"}}
330
+ if !isSupportedGitHubSearchURLQuery(valid) {
331
+ t.Fatal("valid search query rejected")
332
+ }
333
+ invalid := url.Values{"q": []string{"x"}, "type": []string{"repositories"}, "p": []string{"zero"}}
334
+ if isSupportedGitHubSearchURLQuery(invalid) {
335
+ t.Fatal("invalid search page accepted")
336
+ }
337
+ }
338
+
339
+ func TestGitHubSearchEmptyPageIsExplicit(t *testing.T) {
340
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
341
+ w.Header().Set("Content-Type", "application/json")
342
+ _, _ = io.WriteString(w, `{"total_count":0,"incomplete_results":false,"items":[]}`)
343
+ }))
344
+ defer server.Close()
345
+ out, err := readGitHubSearch(context.Background(), testGitHubClient(server.URL, server.URL, ""), parseGitHubTarget("https://github.com/search?q=does-not-exist&type=repositories"))
346
+ if err != nil {
347
+ t.Fatal(err)
348
+ }
349
+ if !strings.Contains(out, "_No results returned on this page._") {
350
+ t.Fatalf("empty Search page was not explicit:\n%s", out)
351
+ }
352
+ }