e2e/activity_test.go
121 lines · 4250 bytes
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "regexp"
7 "slices"
8 "strconv"
9 "strings"
10 "testing"
11)
12
13func TestActivityGraph(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 // bob's email is NOT verified: his commits must not attribute.
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21
22 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatal("repo create failed")
24 }
25 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
26 t.Fatal("grant failed")
27 }
28
29 work := t.TempDir()
30 env := inst.gitEnv(aliceKey)
31 // gitEnv pins GIT_AUTHOR_EMAIL; author identity comes from the env.
32 // Clone before appending: sharing env's backing array would let the
33 // second append clobber the first.
34 aliceEnv := append(slices.Clone(env), "GIT_AUTHOR_EMAIL=alice@example.test", "GIT_AUTHOR_NAME=Alice")
35 bobEnv := append(slices.Clone(env), "GIT_AUTHOR_EMAIL=bob@nowhere.test", "GIT_AUTHOR_NAME=Bob")
36 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
37 dir := filepath.Join(work, "w")
38 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
39 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
40 mustGit(t, dir, env, "add", ".")
41 mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "one")
42 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
43 mustGit(t, dir, env, "add", ".")
44 mustGit(t, dir, bobEnv, "commit", "-q", "-m", "two")
45 mustGit(t, dir, env, "push", "-q", "origin", "main")
46
47 // Today's cell counts alice's commit; bob's unverified authorship
48 // contributes nothing and issue creation counts as an event.
49 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
50 t.Fatal("issue create failed")
51 }
52 status, body := inst.get(t, "/alice")
53 if status != 200 || !strings.Contains(body, `class="actgraph"`) {
54 t.Fatalf("graph missing: %d", status)
55 }
56 // alice: 1 attributed commit + events (issue.created, ...). The exact
57 // cells depend on author-date timezone vs event UTC, so assert the
58 // year total instead.
59 total := activityTotal(t, body)
60 if total < 2 {
61 t.Fatalf("alice total = %d, want >= 2", total)
62 }
63 // bob authored a commit but his email is unverified: zero activity.
64 _, bobBody := inst.get(t, "/bob")
65 if bt := activityTotal(t, bobBody); bt != 0 {
66 t.Fatalf("unverified author got credit: total %d", bt)
67 }
68
69 // Re-pushing the same history (force) does not double-count.
70 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main")
71 _, body2 := inst.get(t, "/alice")
72 if body2 != body {
73 // Counts must be identical; compare just the graph cells.
74 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") {
75 t.Fatal("re-push changed activity counts")
76 }
77 }
78
79 // Org pages aggregate their repos' activity.
80 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "theorg"); code != 0 {
81 t.Fatal("org create failed")
82 }
83 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 {
84 t.Fatal("transfer failed")
85 }
86 _, orgBody := inst.get(t, "/theorg")
87 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") {
88 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity"))
89 }
90
91 // Backfill is idempotent and attributes only verified authors.
92 out := inst.admin(t, "admin", "backfill-activity")
93 if !strings.Contains(out, "theorg/app\t0 attributed") {
94 t.Fatalf("backfill re-attributed existing commits: %s", out)
95 }
96}
97
98// excerpt returns ~600 bytes around the first occurrence of marker.
99func excerpt(s, marker string) string {
100 i := strings.Index(s, marker)
101 if i < 0 {
102 return "(marker missing)"
103 }
104 end := i + 600
105 if end > len(s) {
106 end = len(s)
107 }
108 return s[i:end]
109}
110
111var totalPat = regexp.MustCompile(`([0-9]+) in the last year`)
112
113func activityTotal(t *testing.T, body string) int {
114 t.Helper()
115 m := totalPat.FindStringSubmatch(body)
116 if m == nil {
117 t.Fatal("activity total missing from page")
118 }
119 n, _ := strconv.Atoi(m[1])
120 return n
121}