package web
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/evt"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// memCaps is a caps plane in a map: stored strings are the effective
// set (no categories), and the guard refuses self-demotion from a.
type memCaps struct {
stored map[string]string
cats map[string]string // "repo/category" → string; fleet under ""
}
func (m *memCaps) cat(repo, c string) string {
if v, ok := m.cats[repo+"/"+c]; ok {
return v
}
if v, ok := m.cats["/"+c]; ok {
return v
}
return map[string]string{"nobody": "gjorzh2", "anonymous": "mnc3C"}[c]
}
func (m *memCaps) CapsOf(acct, _ string) CapSet { return oltp.CapSet(m.stored[acct]) }
func (m *memCaps) CapHolders(string) ([]CapHolder, error) {
names := make([]string, 0, len(m.stored))
for n := range m.stored {
names = append(names, n)
}
sort.Strings(names)
var out []CapHolder
for _, n := range names {
out = append(out, CapHolder{Account: n, Caps: m.stored[n]})
}
return out, nil
}
func (m *memCaps) CategoryCaps(repo, cat string) string { return m.cat(repo, cat) }
func (m *memCaps) SetCaps(acct, _, caps string) error { m.stored[acct] = caps; return nil }
func (m *memCaps) SetCategoryCaps(repo, cat, caps string) error {
if m.cats == nil {
m.cats = map[string]string{}
}
m.cats[repo+"/"+cat] = caps
return nil
}
func (m *memCaps) EffectiveWith(_, _, own string) CapSet { return oltp.CapSet(own) }
func (m *memCaps) GuardCaps(actor, acct, repo, caps string) (string, error) {
if actor == acct && oltp.CapSet(m.stored[acct]).HasAny("a") && !oltp.CapSet(caps).HasAny("a") {
return "", oltp.Refusal{Reason: "that would remove your own admin on " + repo}
}
return "", nil
}
func peopleServer(t *testing.T, plane *memCaps) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
Roster: capsRoster{plane}}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
WithCaps(plane)}))
t.Cleanup(srv.Close)
return srv
}
func postAsDan(t *testing.T, srv *httptest.Server, path string, form url.Values) *http.Response {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
// RFC 0008 §IA: the People section reads each string as a role and a
// sentence; the raw per-account inputs are gone from Settings.
func TestSettingsReadsPeopleAsRolesAndSentences(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Maintain.Caps() + "e", "eve": ""}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
for _, want := range []string{`href="/r/alpha/people/bob"`, `<span class="chip role">Maintainer</span>`, `chip plus">+ `, "bob can "} {
if !strings.Contains(body, want) {
t.Fatalf("settings lacks %q", want)
}
}
if strings.Contains(body, `name="acct-bob"`) {
t.Fatal("raw per-account inputs are still on Settings")
}
if strings.Contains(body, `href="/r/alpha/people/eve"`) {
t.Fatal("eve holds nothing here and should not be listed")
}
}
// The editor: a role radio, the abilities as boxes, the string under a
// disclosure. Saving a role change writes the preset and says so.
func TestPersonEditorSavesARoleChange(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/people/bob")
for _, want := range []string{`value="Developer" checked`, `name="ab"`, `name="raw"`, `name="was-role" value="Developer"`} {
if !strings.Contains(body, want) {
t.Fatalf("editor lacks %q", want)
}
}
resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{
"role": {"Maintainer"}, "was-role": {"Developer"},
"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
"ab": {"u"}, "was-ab": {"u"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.stored["bob"] != oltp.Maintain.Caps() {
t.Fatalf("stored %q", plane.stored["bob"])
}
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "Developer+%E2%86%92+Maintainer") {
t.Fatalf("redirect does not say what changed: %s", loc)
}
if got := asDan(t, srv, loc); !strings.Contains(got, "Saved. Developer → Maintainer") {
t.Fatal("the change line is not shown after save")
}
}
// Bulletproofing: the guard's refusal is shown where the save was, and
// nothing is written.
func TestPersonEditorShowsARefusalInsteadOfSaving(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/people/dan", url.Values{
"role": {"Developer"}, "was-role": {"Admin"}, "raw": {"a"}, "was-raw": {"a"}})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d", resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "Not saved: that would remove your own admin") {
t.Fatal("refusal not shown")
}
if plane.stored["dan"] != "a" {
t.Fatalf("saved anyway: %q", plane.stored["dan"])
}
}
// Adding from the roster form writes the role's string and lands on
// the person's page; an unknown login is refused in words.
func TestRosterAddsAPersonByRole(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "eve": ""}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"eve"}, "role": {"Reader"}})
if resp.StatusCode != http.StatusSeeOther || !strings.HasPrefix(resp.Header.Get("Location"), "/r/alpha/people/eve?changed=added+as+Reader") {
t.Fatalf("%d %s", resp.StatusCode, resp.Header.Get("Location"))
}
if plane.stored["eve"] != oltp.Read.Caps() {
t.Fatalf("stored %q", plane.stored["eve"])
}
resp = postAsDan(t, srv, "/r/alpha/people", url.Values{"name": {"ghost"}, "role": {"Reader"}})
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "no+account+named+ghost") {
t.Fatalf("unknown login: %s", loc)
}
}
// nextCaps: the string wins when edited; a new role replaces; boxes
// are the string; untouched boxes keep the stored bytes.
func TestNextCapsReadsTheFormInOrder(t *testing.T) {
dev := oltp.Write.Caps()
base := url.Values{"role": {"Developer"}, "was-role": {"Developer"}, "raw": {dev}, "was-raw": {dev}, "was-ab": {dev}, "ab": strings.Split(dev, "")}
if got := nextCaps(base, dev); got != dev {
t.Fatalf("untouched form wrote %q", got)
}
f := url.Values{}
for k, v := range base {
f[k] = v
}
f.Set("raw", "iy")
if got := nextCaps(f, dev); got != "iy" {
t.Fatalf("edited string lost: %q", got)
}
f = url.Values{}
for k, v := range base {
f[k] = v
}
f["ab"] = append(f["ab"], "5") // moderate the forum
if got := nextCaps(f, dev); oltp.Closure(got) != oltp.Closure(dev+"5") || strings.Contains(got, "3") {
t.Fatalf("ticked box: %q", got)
}
f.Set("revoke", "1")
if got := nextCaps(f, dev); got != "" {
t.Fatalf("revoke: %q", got)
}
}
// RFC 0008 §Visibility: Settings asks who can see this first, as
// presets; a preset writes the two category strings.
func TestVisibilityPresetsWriteTheCategoryStrings(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, `value="Public project" checked`) || strings.Contains(body, `name="private"`) {
t.Fatal("settings should open on the visibility presets, without the old checkbox")
}
resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Members only"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "gjorzh2mnc3C8" {
t.Fatalf("strings after Members only: %q / %q", plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
}
if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Members only" checked`) {
t.Fatal("the preset does not read back")
}
// Hand-typed strings that match no preset read as Custom, decoded.
plane.cats["alpha/nobody"], plane.cats["alpha/anonymous"] = "o", "o"
if body := asDan(t, srv, "/r/alpha/settings"); !strings.Contains(body, `value="Custom" checked`) || !strings.Contains(body, "visitors can ") {
t.Fatal("Custom is not shown decoded")
}
}
// Private is a wizard: whoever reaches the repository only through
// the categories is named and offered a role before the strings blank.
func TestTakingPrivateOffersRolesToWhoWouldLoseAccess(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "eve": "", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
if loc := resp.Header.Get("Location"); loc != "/r/alpha/settings/private" {
t.Fatalf("went to %s, not the wizard", loc)
}
if plane.cat("alpha", "nobody") != "gjorzh2" {
t.Fatal("the strings blanked before anyone was asked")
}
body := asDan(t, srv, "/r/alpha/settings/private")
if !strings.Contains(body, `name="role-eve"`) || strings.Contains(body, `name="role-bob"`) {
t.Fatal("the wizard should name eve and not bob")
}
resp = postAsDan(t, srv, "/r/alpha/settings/private", url.Values{"confirm": {"1"}, "role-eve": {"Reader"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if plane.stored["eve"] != oltp.Read.Caps() || plane.cat("alpha", "nobody") != "" || plane.cat("alpha", "anonymous") != "" {
t.Fatalf("after confirm: eve=%q nobody=%q anon=%q", plane.stored["eve"], plane.cat("alpha", "nobody"), plane.cat("alpha", "anonymous"))
}
// With nobody to lose access, Private is one step.
plane.stored["eve"] = oltp.Read.Caps()
plane.cats = map[string]string{}
resp = postAsDan(t, srv, "/r/alpha/settings/visibility", url.Values{"visibility": {"Private"}})
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/r/alpha/settings") {
t.Fatalf("went to %s", loc)
}
// The confirmation travels as a one-shot flash, not in the URL: a
// query parameter stays in the address bar and a reload replays it.
// Asserting the cookie is asserting the contract; the old check
// pinned the spelling of a mechanism instead.
if got := strings.Join(resp.Header.Values("Set-Cookie"), " "); !strings.Contains(got, "q_flash=visibility") {
t.Errorf("visibility saved without a flash: %q", got)
}
}
// RFC 0008 §IA: Settings reads top to bottom in the order of the
// questions — who can see this, people, branches, repository,
// integrations, advanced, danger — and the raw category strings live
// under the Advanced disclosure with a decoder beside each.
func TestSettingsSectionsAreInIAOrder(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
// protected and integrations are conditional on planes this fixture
// does not wire; the rest prove the top-to-bottom order.
order := []string{
`id="visibility"`, `id="people"`, `id="about"`, `id="access"`, `id="danger"`,
}
at := -1
for _, marker := range order {
i := strings.Index(body, marker)
if i < 0 {
t.Fatalf("section %q missing", marker)
}
if i < at {
t.Fatalf("section %q is out of IA order", marker)
}
at = i
}
// The raw strings are Advanced now, decoded in place.
if !strings.Contains(body, "<summary>Capability letters") {
t.Fatal("category strings are not under the Advanced disclosure")
}
if !strings.Contains(body, "— can ") {
t.Fatal("no decoder beside the category inputs")
}
}
// RFC 0008 rung 7: the live preview recomputes the whole region from
// the form and morphs it — the sentence, the checkboxes and the
// string are one signal — without writing anything. Ticking a box the
// string does not yet grant comes back with the string widened.
func TestPreviewFragRecomputesWithoutWriting(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
// The page arms the controls when the plane can answer what-if.
page := asDan(t, srv, "/r/alpha/people/bob")
if !strings.Contains(page, "preview.frag") || !strings.Contains(page, `id="person-live"`) {
t.Fatal("the editor is not armed for live preview")
}
// Tick "moderate the forum" (5): the string was Developer's.
form := url.Values{"role": {"Developer"}, "was-role": {"Developer"},
"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
"was-ab": {oltp.Write.Caps()}}
for _, l := range strings.Split(oltp.Write.Caps(), "") {
form.Add("ab", l)
}
form.Add("ab", "5")
req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/people/bob/preview.frag?"+form.Encode(), nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type %q", ct)
}
b, _ := io.ReadAll(resp.Body)
body := string(b)
if !strings.Contains(body, "datastar-patch-elements") || !strings.Contains(body, `id="person-live"`) {
t.Fatal("not a person-live morph")
}
// The widened string is echoed back and the change line is shown;
// nothing was written.
if !strings.Contains(body, "moderate the forum") {
t.Fatal("the widened ability is not named in the preview")
}
i := strings.Index(body, `id="person-raw"`)
if i < 0 || !strings.Contains(body[i:i+120], "5") {
t.Fatal("the string input does not reflect the ticked ability")
}
if plane.stored["bob"] != oltp.Write.Caps() {
t.Fatalf("preview wrote to the store: %q", plane.stored["bob"])
}
}
// A candidate the guard refuses is previewed with the reason, still
// without writing.
func TestPreviewFragShowsARefusal(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
form := url.Values{"role": {"Developer"}, "was-role": {"Admin"}, "raw": {"a"}, "was-raw": {"a"}}
req, _ := http.NewRequest("GET", srv.URL+"/r/alpha/people/dan/preview.frag?"+form.Encode(), nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "remove your own admin") {
t.Fatal("refusal not previewed")
}
if plane.stored["dan"] != "a" {
t.Fatal("preview wrote")
}
}
// RFC 0008 rung 8: the Advanced section carries the fossil settings and
// configuration surfaces. On a store with no working tree they render
// the honest blocked-on-upstream note (zit#153), never a fake editor.
func TestFossilSettingsShowTheUpstreamNote(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane) // its repo has an Index but no Zit client
body := asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, "Fossil settings") || !strings.Contains(body, "Configuration areas") {
t.Fatal("the Advanced fossil surfaces are missing")
}
if !strings.Contains(body, "no store to read settings") {
t.Fatalf("the no-store fallback note is not shown")
}
// No editable settings table when the store cannot answer.
if strings.Contains(body, `name="set-`) {
t.Fatal("a settings editor was drawn without a store to back it")
}
}
// Audit follow-up (ticket ed5bdd89c39a): the raw acct- fields left the
// Settings form in rung 6, but the endpoint still accepts them — so a
// stale page or a hand-built POST meets the same guard the editor has.
func TestRawCapsEndpointHonorsTheGuard(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
srv := peopleServer(t, plane)
resp := postAsDan(t, srv, "/r/alpha/settings/caps", url.Values{"acct-dan": {"uv"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "e=") || !strings.Contains(loc, "your+own+admin") {
t.Fatalf("refusal not surfaced: %s", loc)
}
if plane.stored["dan"] != "a" {
t.Fatalf("saved through the guard: %q", plane.stored["dan"])
}
// A permitted raw save still goes through.
resp = postAsDan(t, srv, "/r/alpha/settings/caps", url.Values{"acct-bob": {"u"}})
if resp.StatusCode != http.StatusSeeOther || plane.stored["bob"] != "u" {
t.Fatalf("permitted save refused: %d %q", resp.StatusCode, plane.stored["bob"])
}
}
// The page describes a NAME. Two repositories using the same name is
// not evidence of one person — fossil's U card is a bare string — so
// the page must not sum them into a single contributor, and must say
// so when the name is shared.
func TestPersonPageDoesNotInventAContributor(t *testing.T) {
shared := personRow{User: "dan", Events: 7800,
Appearances: []personAppearance{
{Repo: "sqlite", Events: 7002, First: "2007-07-28T00:00:00Z", Last: "2026-08-30T00:00:00Z"},
{Repo: "quarry", Events: 5, First: "2026-08-29T00:00:00Z", Last: "2026-08-31T00:00:00Z"},
}}
var buf bytes.Buffer
if err := personPage.Execute(&buf, pageData{Title: "dan", HideH1: true, Person: shared}); err != nil {
t.Fatal(err)
}
html := buf.String()
if strings.Contains(html, `title="7800"`) || strings.Contains(html, "7.8K") {
t.Error("the page still sums a shared name into one contributor")
}
if !strings.Contains(html, "These may be different people") {
t.Error("a shared name carries no warning that it may be several people")
}
// Each repository's OWN figure. count renders a titled span, so the
// exact number is asserted through the title the forge puts there.
for _, want := range []string{"sqlite", "quarry", `title="7002"`, `title="5"`} {
if !strings.Contains(html, want) {
t.Errorf("the per-repository breakdown is missing %q", want)
}
}
// A name in ONE repository is not ambiguous, and should not be
// warned about — a caution on every page is a caution nobody reads.
var solo bytes.Buffer
if err := personPage.Execute(&solo, pageData{Title: "drh", HideH1: true,
Person: personRow{User: "drh", Events: 9,
Appearances: []personAppearance{{Repo: "sqlite", Events: 9}}}}); err != nil {
t.Fatal(err)
}
if strings.Contains(solo.String(), "These may be different people") {
t.Error("a name in one repository is being warned about")
}
}
// Who may speak is asked apart from who may see: the boxes write the
// participation letters onto the anonymous string, and the preset
// keeps its name whatever is ticked.
func TestParticipationBoxesWriteTheAnonymousString(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a"}}
srv := peopleServer(t, plane)
body := asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, `name="part" value="3" checked`) || !strings.Contains(body, `name="part" value="C" checked`) {
t.Fatalf("a public project does not show forum and chat ticked")
}
resp := postAsDan(t, srv, "/r/alpha/settings/participation", url.Values{"part": {"nc"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status %d", resp.StatusCode)
}
if got := plane.cat("alpha", "anonymous"); got != "mnc" {
t.Fatalf("anonymous after tickets only = %q", got)
}
body = asDan(t, srv, "/r/alpha/settings")
if !strings.Contains(body, `value="Public project" checked`) {
t.Fatal("clearing a box turned the preset into Custom")
}
if strings.Contains(body, `name="part" value="3" checked`) || !strings.Contains(body, `name="part" value="nc" checked`) {
t.Fatal("the boxes do not read back")
}
}
// capsRoster adapts the page's caps plane to api.CapsWriter: the STORED
// string, which is what a role change is described by, where the page's
// own CapsOf answers the effective set a gate asks about.
type capsRoster struct{ m *memCaps }
func (c capsRoster) CapsOf(acct, _ string) (string, error) { return c.m.stored[acct], nil }
func (c capsRoster) SetCaps(acct, repo, caps string) error { return c.m.SetCaps(acct, repo, caps) }
// The settings editor writes through account.caps now, not straight to
// the plane — which is the whole point: a role changed here raised
// nothing, so it left no trace in the trail on this very page and
// reached no webhook.
func TestTheEditorRaisesTheAccountEvent(t *testing.T) {
plane := &memCaps{stored: map[string]string{"dan": "a", "bob": oltp.Write.Caps()}}
r := ops.NewRegistry()
ev := &countingEvents{}
if err := api.RegisterAll(r, api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: landingIndex(t, false)}}),
Roster: capsRoster{plane}, Events: ev}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan", roles: map[string]ops.Auth{"alpha": ops.Admin}}),
WithCaps(plane)}))
t.Cleanup(srv.Close)
resp := postAsDan(t, srv, "/r/alpha/people/bob", url.Values{
"role": {"Maintainer"}, "was-role": {"Developer"},
"raw": {oltp.Write.Caps()}, "was-raw": {oltp.Write.Caps()},
"ab": {"u"}, "was-ab": {"u"}})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("FAIL: status %d", resp.StatusCode)
}
if plane.stored["bob"] != oltp.Maintain.Caps() {
t.Fatalf("FAIL: stored %q — the write did not go through the op", plane.stored["bob"])
}
if got := ev.kinds(); len(got) != 1 || got[0] != "account.caps" {
t.Errorf("FAIL: the editor raised %v, want one account.caps", got)
}
}
// countingEvents records the kinds an op raised.
type countingEvents struct {
mu sync.Mutex
seen []string
}
func (c *countingEvents) Raise(kind evt.Kind, _ string, _ map[string]any) error {
c.mu.Lock()
defer c.mu.Unlock()
c.seen = append(c.seen, string(kind))
return nil
}
func (c *countingEvents) kinds() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.seen...)
}