diff --git a/e2e/connect/connect_test.go b/e2e/connect/connect_test.go new file mode 100644 index 000000000..0b94f5e37 --- /dev/null +++ b/e2e/connect/connect_test.go @@ -0,0 +1,251 @@ +//go:build linux || darwin + +package connect + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/connector/fakebasecamp" +) + +// Bystander is someone on the served project whom the agent does not take +// instructions from: the default trust mode trusts only the operator. +const ( + BystanderID int64 = 26909601 + BystanderName = "Bystander" +) + +// world is the default world with the bystander on the served project. +func world() *fakebasecamp.World { + w := fakebasecamp.DefaultWorld() + w.People[BystanderID] = &fakebasecamp.Person{ID: BystanderID, Name: BystanderName} + p := w.Projects[fakebasecamp.ProjectID] + p.Members = append(p.Members, BystanderID) + return w +} + +// kickoffID is the message every comment here is on. +const kickoffID int64 = 1_000_000 + +// comment adds a comment by author on the kickoff message, which it adds +// the first time, and emits the comment's comment.created on lanes. +func comment(h *Harness, author int64, content string, lanes fakebasecamp.Lane) fakebasecamp.Event { + var id int64 + h.Fake.Update(func(w *fakebasecamp.World) { + if _, ok := w.Recordings[kickoffID]; !ok { + w.Recordings[kickoffID] = &fakebasecamp.Recording{ + ID: kickoffID, Type: "Message", BucketID: fakebasecamp.ProjectID, CreatorID: fakebasecamp.OperatorID, Title: "Kickoff", + } + } + id = kickoffID + int64(len(w.Recordings)) + w.Recordings[id] = &fakebasecamp.Recording{ + ID: id, Type: "Comment", BucketID: fakebasecamp.ProjectID, ParentID: kickoffID, CreatorID: author, Content: content, + } + }) + return h.Fake.Emit(fakebasecamp.Event{ + EventType: "comment.created", BucketID: fakebasecamp.ProjectID, RecordingID: id, CreatorID: author, + }, lanes) +} + +// askAgent is a comment that asks the agent for something by mentioning it. +func askAgent() string { + return "
Could you review the launch plan, " + fakebasecamp.Mention(fakebasecamp.AgentID) + "? Thanks
" +} + +// streaming sets the agent up, starts the connector, and waits until an +// event published live reaches it. +func streaming(t *testing.T) (*Harness, *Connector) { + t.Helper() + h := NewHarness(t, world()) + h.Setup(t) + c := h.Connect(t) + c.WaitStreaming(t, 1) + return h, c +} + +// The setup a person runs, through the CLI: the connection ceremony, then +// connect setup, which writes connect.json owner-only. +func TestSetupThroughTheCLIWritesConnectJSON(t *testing.T) { + t.Parallel() + h := NewHarness(t, world()) + h.Setup(t) + + assert.Equal(t, 1, h.Fake.Count(fakebasecamp.RouteAgentConnections), "one connection ceremony") + info, err := os.Stat(h.ConnectJSON()) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + data, err := os.ReadFile(h.ConnectJSON()) + require.NoError(t, err) + var file struct { + Profile string `json:"profile"` + AccountID string `json:"account_id"` + Agent struct { + PersonID int64 `json:"person_id"` + Kind string `json:"kind"` + } `json:"agent"` + Trust struct { + Mode string `json:"mode"` + OperatorID int64 `json:"operator_id"` + } `json:"trust"` + Projects map[string]json.RawMessage `json:"projects"` + } + require.NoError(t, json.Unmarshal(data, &file)) + assert.Equal(t, Agent, file.Profile) + assert.Equal(t, strconv.FormatInt(fakebasecamp.AccountID, 10), file.AccountID) + assert.Equal(t, fakebasecamp.AgentID, file.Agent.PersonID) + assert.Equal(t, "agent", file.Agent.Kind) + assert.Equal(t, "operator", file.Trust.Mode) + assert.Equal(t, fakebasecamp.OperatorID, file.Trust.OperatorID) + assert.Equal(t, []string{strconv.FormatInt(fakebasecamp.ProjectID, 10)}, keys(file.Projects)) +} + +// The operator mentions the agent: the connector writes the event's pointer, +// admits it, and hands it off in exactly one request line that says what +// was asked, where, by whom, and where to reply, with the agent's own +// mention taken out of the words. +func TestATrustedMentionIsHandedOffInOneRequestLine(t *testing.T) { + t.Parallel() + h, c := streaming(t) + ev := comment(h, fakebasecamp.OperatorID, askAgent(), fakebasecamp.Live) + c.WaitFor(t, "the request line", func(o Output) bool { return len(o.RequestsFor(ev.ID)) > 0 }) + out := c.Stop(t) + + pointers := out.PointersFor(ev.ID) + require.Len(t, pointers, 1) + assert.Equal(t, "live", pointers[0].Lane) + assert.Equal(t, ev.RecordingID, pointers[0].RecordingID) + + events := out.EventsFor(ev.ID) + require.Len(t, events, 1) + assert.Equal(t, "admitted", events[0].State) + assert.Equal(t, "mentioned", events[0].Trigger) + + requests := out.RequestsFor(ev.ID) + require.Len(t, requests, 1) + req := requests[0] + assert.Equal(t, "request", req.Type) + assert.Equal(t, "comment.created", req.EventType) + assert.Equal(t, "mentioned", req.Trigger) + assert.Equal(t, RequestRecording{ + BucketID: fakebasecamp.ProjectID, + ProjectName: fakebasecamp.ProjectName, + RecordingID: ev.RecordingID, + Type: "Comment", + URL: h.Fake.URL() + "/" + strconv.FormatInt(fakebasecamp.AccountID, 10) + "/comments/" + strconv.FormatInt(ev.RecordingID, 10), + }, req.Recording) + assert.Equal(t, RequestReplyTo{Kind: "comment", RecordingID: kickoffID}, req.ReplyTo) + assert.Equal(t, fakebasecamp.OperatorID, req.RequesterID) + assert.Equal(t, fakebasecamp.OperatorName, req.RequesterName) + assert.True(t, req.Acknowledge, "a person asked for something") + // The mention leaves a space where it was, so no markup can close over + // the gap, and nothing else of the words changes. + assert.Equal(t, strings.Replace(askAgent(), fakebasecamp.Mention(fakebasecamp.AgentID), " ", 1), req.Content) +} + +// Basecamp publishes an event on both lanes: live first, and on the poll +// lane the connector walks when it reconnects. It is one event, so it is +// one pointer line and one request line. +func TestAnEventOnBothLanesIsHandedOffOnce(t *testing.T) { + t.Parallel() + h, c := streaming(t) + ev := comment(h, fakebasecamp.OperatorID, askAgent(), fakebasecamp.Both) + c.WaitFor(t, "the request line", func(o Output) bool { return len(o.RequestsFor(ev.ID)) > 0 }) + before := len(h.Fake.Requests()) + + // The reconnect's catch-up walks the poll lane from where the first + // walk ended, before the event, so the poll lane serves it again. + require.Equal(t, 1, h.Fake.DropCable()) + c.WaitStreaming(t, 2) + served := false + for _, r := range h.Fake.Requests()[before:] { + if r.Route == fakebasecamp.RouteEvents && r.Status == http.StatusOK && cursorBefore(r.Query.Get("position"), ev.ID) { + served = true + } + } + require.True(t, served, "the reconnect walked the poll lane from before the event") + + // Something after it, live, to be sure the connector has dealt with all + // the walk delivered before it stops. + after := comment(h, fakebasecamp.OperatorID, askAgent(), fakebasecamp.Live) + c.WaitFor(t, "the request line after it", func(o Output) bool { return len(o.RequestsFor(after.ID)) > 0 }) + out := c.Stop(t) + + assert.Len(t, out.PointersFor(ev.ID), 1) + assert.Len(t, out.EventsFor(ev.ID), 1) + assert.Len(t, out.RequestsFor(ev.ID), 1) +} + +// Someone the agent does not take instructions from mentions it: the +// connector says so in an event line, and hands nothing off. +func TestAnUntrustedMentionIsDiscardedAndNotHandedOff(t *testing.T) { + t.Parallel() + h, c := streaming(t) + ev := comment(h, BystanderID, askAgent(), fakebasecamp.Live) + c.WaitFor(t, "the event line", func(o Output) bool { return len(o.EventsFor(ev.ID)) > 0 }) + out := c.Stop(t) + + events := out.EventsFor(ev.ID) + require.Len(t, events, 1) + assert.Equal(t, "discarded", events[0].State) + assert.Equal(t, "untrusted_performer", events[0].Reason) + assert.Equal(t, BystanderID, events[0].RequesterID) + assert.Empty(t, out.RequestsFor(ev.ID)) + assert.Empty(t, out.Requests()) +} + +// Stdout is a protocol a program parses: every line one JSON object of a +// shape the protocol has, and nothing else. What a person reads goes to +// stderr, as slog lines, starting with the connector saying it runs. +func TestStdoutIsOnlyNDJSONAndStderrSaysItRuns(t *testing.T) { + t.Parallel() + h, c := streaming(t) + trusted := comment(h, fakebasecamp.OperatorID, askAgent(), fakebasecamp.Live) + untrusted := comment(h, BystanderID, askAgent(), fakebasecamp.Live) + c.WaitFor(t, "both verdicts and the request", func(o Output) bool { + return len(o.RequestsFor(trusted.ID)) > 0 && len(o.EventsFor(untrusted.ID)) > 0 + }) + out := c.Stop(t) + + require.NotEmpty(t, out.Lines) + for _, l := range out.Lines { + assert.True(t, json.Valid([]byte(l.Raw)), "not JSON: %q", l.Raw) + assert.NoError(t, l.Err, "%q", l.Raw) + } + assert.NotEmpty(t, out.Pointers()) + assert.NotEmpty(t, out.Events()) + assert.NotEmpty(t, out.Requests()) + + running := out.Logged(MsgRunning) + require.Len(t, running, 1) + assert.Equal(t, "INFO", running[0].Level) + assert.Equal(t, Agent, running[0].Attrs["profile"]) + assert.Equal(t, strconv.FormatInt(fakebasecamp.AccountID, 10), running[0].Attrs["account"]) + assert.Equal(t, strconv.FormatInt(fakebasecamp.AgentID, 10), running[0].Attrs["agent_person_id"]) + for _, l := range out.Logs { + assert.NotEmpty(t, l.Msg, "stderr line is not a slog record: %q", l.Raw) + } +} + +// cursorBefore reports whether a poll position the fake issued is before +// an event id. +func cursorBefore(position string, id int64) bool { + n, err := strconv.ParseInt(strings.TrimPrefix(position, "fake-"), 10, 64) + return strings.HasPrefix(position, "fake-") && err == nil && n < id +} + +func keys(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/e2e/connect/connector_test.go b/e2e/connect/connector_test.go new file mode 100644 index 000000000..085385fa0 --- /dev/null +++ b/e2e/connect/connector_test.go @@ -0,0 +1,364 @@ +//go:build linux || darwin + +package connect + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +// Messages the connector logs that tests wait on. +const ( + // MsgRunning is logged once the connector holds its lock and its + // ledger, just before intake starts. A signal before it may land + // before the connector's handler is installed. + MsgRunning = "connector: running" + // MsgStreaming is logged each time the feed's catch-up walk reaches the + // head with the live buffer drained: from then on an event published + // live is delivered as it is published. + MsgStreaming = "feed walk reached its head and the buffer drained" +) + +// exitInterrupted is how the connector exits on SIGINT. +const exitInterrupted = 130 + +// Connector is one CLI process the test runs in the background, usually +// `basecamp connect`, with everything it writes read as it is written. +type Connector struct { + t *testing.T + what string + cmd *exec.Cmd + // exited is closed once the process has exited and both of its outputs + // are read to the end. + exited chan struct{} + + mu sync.Mutex + // changed is closed, and replaced, whenever out changes. + changed chan struct{} + out Output +} + +// Output is everything a Connector has written so far, and how it ended +// once it has. +type Output struct { + // Lines are its stdout lines, in order. + Lines []Line + // Logs are its stderr lines, in order. + Logs []LogLine + // Exited is set once the process has exited and its output is all read; + // ExitCode is then its exit code, -1 when a signal killed it. + Exited bool + ExitCode int +} + +// Connect starts `basecamp connect -P agent` with extra flags, in the +// harness's home. +func (h *Harness) Connect(t *testing.T, extra ...string) *Connector { + t.Helper() + return h.Start(t, h.Home, append([]string{"connect", "-P", Agent}, extra...)...) +} + +// Start starts a CLI command in the background, in dir, with the harness's +// environment. Whatever it is doing when the test ends, it is killed (the +// test's context ends just before its cleanup runs) and waited for, and its +// output is logged when the test failed. +func (h *Harness) Start(t *testing.T, dir string, args ...string) *Connector { + t.Helper() + cmd := exec.CommandContext(t.Context(), binary(t), args...) + cmd.Env = h.env + cmd.Dir = dir + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + stderr, err := cmd.StderrPipe() + require.NoError(t, err) + require.NoError(t, cmd.Start()) + + c := &Connector{ + t: t, + what: "basecamp " + strings.Join(args, " "), + cmd: cmd, + exited: make(chan struct{}), + changed: make(chan struct{}), + } + var readers sync.WaitGroup + readers.Go(func() { + readLines(stdout, func(raw string, complete bool) { + line := parseLine(raw) + if !complete && line.Err == nil { + line.Err = errors.New("the line was not ended: torn") + } + c.update(func(o *Output) { o.Lines = append(o.Lines, line) }) + }) + }) + readers.Go(func() { + readLines(stderr, func(raw string, _ bool) { + c.update(func(o *Output) { o.Logs = append(o.Logs, parseLogLine(raw)) }) + }) + }) + go func() { + // Wait closes the pipes, so it waits for the readers to reach EOF + // first: the process's exit closes its ends. + readers.Wait() + err := cmd.Wait() + code := 0 + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else if err != nil { + code = -1 + } + c.update(func(o *Output) { o.Exited, o.ExitCode = true, code }) + close(c.exited) + }() + t.Cleanup(c.cleanup) + return c +} + +// readLines calls fn with each line r yields, without its newline, until +// r ends. complete is false for a last line that had no newline. +func readLines(r io.Reader, fn func(raw string, complete bool)) { + br := bufio.NewReader(r) + for { + line, err := br.ReadString('\n') + if strings.HasSuffix(line, "\n") { + fn(strings.TrimSuffix(line, "\n"), true) + } else if line != "" { + fn(line, false) + } + if err != nil { + return + } + } +} + +func (c *Connector) update(fn func(o *Output)) { + c.mu.Lock() + defer c.mu.Unlock() + fn(&c.out) + close(c.changed) + c.changed = make(chan struct{}) +} + +// Output is what the process has written so far. +func (c *Connector) Output() Output { + c.mu.Lock() + defer c.mu.Unlock() + return c.out.snapshot() +} + +func (o *Output) snapshot() Output { + return Output{ + Lines: append([]Line(nil), o.Lines...), + Logs: append([]LogLine(nil), o.Logs...), + Exited: o.Exited, + ExitCode: o.ExitCode, + } +} + +// WaitFor returns the output once cond holds of it. It fails the test when +// the process exits with cond still false, or when waitFor passes first. +// cond is called again whenever the process writes, never on a timer. +func (c *Connector) WaitFor(t *testing.T, what string, cond func(o Output) bool) Output { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), waitFor) + defer cancel() + for { + c.mu.Lock() + out, changed := c.out.snapshot(), c.changed + c.mu.Unlock() + if cond(out) { + return out + } + if out.Exited { + t.Fatalf("%s exited with code %d before %s\n%s", c.what, out.ExitCode, what, out.Transcript()) + } + select { + case <-changed: + case <-ctx.Done(): + t.Fatalf("%s: no %s within %s\n%s", c.what, what, waitFor, c.Output().Transcript()) + } + } +} + +// WaitLogged waits for the nth (from 1) stderr record with msg. +func (c *Connector) WaitLogged(t *testing.T, msg string, nth int) Output { + t.Helper() + return c.WaitFor(t, "log line "+msg, func(o Output) bool { return len(o.Logged(msg)) >= nth }) +} + +// WaitRunning waits for the connector to say it is running. +func (c *Connector) WaitRunning(t *testing.T) { + t.Helper() + c.WaitLogged(t, MsgRunning, 1) +} + +// WaitStreaming waits for the nth time the feed has caught up and is +// streaming: 1 for the first connection, one more for each reconnect. +func (c *Connector) WaitStreaming(t *testing.T, nth int) { + t.Helper() + c.WaitLogged(t, MsgStreaming, nth) +} + +// Signal sends sig to the process as it stands. Interrupt is the safe way +// to send SIGINT. +func (c *Connector) Signal(t *testing.T, sig os.Signal) { + t.Helper() + require.NoError(t, c.cmd.Process.Signal(sig)) +} + +// Interrupt sends SIGINT once the connector has said it is running, never +// before: a signal that lands before its handler is installed kills it +// without the shutdown under test. +func (c *Connector) Interrupt(t *testing.T) { + t.Helper() + c.WaitRunning(t) + c.Signal(t, syscall.SIGINT) +} + +// Kill kills the process outright, as kill -9 does. +func (c *Connector) Kill(t *testing.T) { + t.Helper() + c.Signal(t, syscall.SIGKILL) +} + +// Wait waits for the process to exit and its output to be read, and returns +// its output, whose ExitCode is how it ended. +func (c *Connector) Wait(t *testing.T) Output { + t.Helper() + return c.WaitFor(t, "exit", func(o Output) bool { return o.Exited }) +} + +// Stop interrupts the connector, as a person's Ctrl-C does, and requires +// that it shuts down as it should for one. Everything it wrote is then in +// the output it returns: assertions that something was not written belong +// after it. +func (c *Connector) Stop(t *testing.T) Output { + t.Helper() + c.Interrupt(t) + out := c.Wait(t) + require.Equal(t, exitInterrupted, out.ExitCode, "exit code on SIGINT\n%s", out.Transcript()) + return out +} + +// cleanup waits for the process, which the end of the test's context has +// killed if it was still running, and holds every run to what any run must +// do: write only protocol lines to stdout, and provoke no race. +func (c *Connector) cleanup() { + t := c.t + <-c.exited + out := c.Output() + for i, l := range out.Lines { + if l.Err != nil { + t.Errorf("%s: stdout line %d is not a protocol line (%v): %q", c.what, i+1, l.Err, l.Raw) + } + } + raw := make([]string, len(out.Logs)) + for i, l := range out.Logs { + raw[i] = l.Raw + } + requireNoRace(t, c.what, strings.Join(raw, "\n")) + if t.Failed() { + t.Logf("%s\n%s", c.what, out.Transcript()) + } +} + +// Transcript is the output as written, for a failure message. +func (o Output) Transcript() string { + var b strings.Builder + b.WriteString("--- stdout\n") + for _, l := range o.Lines { + b.WriteString(l.Raw + "\n") + } + b.WriteString("--- stderr\n") + for _, l := range o.Logs { + b.WriteString(l.Raw + "\n") + } + return b.String() +} + +// Pointers are the pointer lines written so far. +func (o Output) Pointers() []PointerLine { + var out []PointerLine + for _, l := range o.Lines { + if l.Pointer != nil { + out = append(out, *l.Pointer) + } + } + return out +} + +// Events are the verdict lines written so far. +func (o Output) Events() []EventLine { + var out []EventLine + for _, l := range o.Lines { + if l.Event != nil { + out = append(out, *l.Event) + } + } + return out +} + +// Requests are the request lines written so far. +func (o Output) Requests() []RequestLine { + var out []RequestLine + for _, l := range o.Lines { + if l.Request != nil { + out = append(out, *l.Request) + } + } + return out +} + +// PointersFor are the pointer lines for one event. +func (o Output) PointersFor(eventID int64) []PointerLine { + var out []PointerLine + for _, p := range o.Pointers() { + if p.EventID == eventID { + out = append(out, p) + } + } + return out +} + +// EventsFor are the verdict lines for one event. +func (o Output) EventsFor(eventID int64) []EventLine { + var out []EventLine + for _, e := range o.Events() { + if e.EventID == eventID { + out = append(out, e) + } + } + return out +} + +// RequestsFor are the request lines for one event. +func (o Output) RequestsFor(eventID int64) []RequestLine { + var out []RequestLine + for _, r := range o.Requests() { + if r.EventID == eventID { + out = append(out, r) + } + } + return out +} + +// Logged are the stderr records with msg. +func (o Output) Logged(msg string) []LogLine { + var out []LogLine + for _, l := range o.Logs { + if l.Msg == msg { + out = append(out, l) + } + } + return out +} diff --git a/e2e/connect/dev_tag_stub_test.go b/e2e/connect/dev_tag_stub_test.go new file mode 100644 index 000000000..de31f3cd9 --- /dev/null +++ b/e2e/connect/dev_tag_stub_test.go @@ -0,0 +1,5 @@ +//go:build (linux || darwin) && !dev + +package connect + +const devBuild = false diff --git a/e2e/connect/dev_tag_test.go b/e2e/connect/dev_tag_test.go new file mode 100644 index 000000000..fe8de72fd --- /dev/null +++ b/e2e/connect/dev_tag_test.go @@ -0,0 +1,8 @@ +//go:build (linux || darwin) && dev + +package connect + +// devBuild says this test binary was built with -tags dev, so the basecamp +// binary it runs is built with it too, as internal/commands/dev_tag.go +// would see it. +const devBuild = true diff --git a/e2e/connect/harness_test.go b/e2e/connect/harness_test.go new file mode 100644 index 000000000..2d20e5400 --- /dev/null +++ b/e2e/connect/harness_test.go @@ -0,0 +1,148 @@ +//go:build linux || darwin + +package connect + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/connector/fakebasecamp" +) + +// waitFor bounds every wait here: a command to finish, a line to be +// written, the fake to see a request. It only bounds a failure; nothing +// waits it out. It covers a reconnect's backoff, which starts at a second +// of full jitter, under the race detector on a loaded runner. +const waitFor = 30 * time.Second + +// Agent is the profile every test connects the default world's agent as. +const Agent = "agent" + +// Harness is one test's Basecamp, and a home of its own to run the CLI in: +// its config, its credentials and the connector's state are the test's +// alone. +type Harness struct { + t *testing.T + // Fake is the Basecamp the CLI talks to. + Fake *fakebasecamp.Server + // Home is HOME; the XDG directories are under it. + Home string + + env []string +} + +// NewHarness serves world on the fake, and makes a home that points the CLI +// at it. +func NewHarness(t *testing.T, world *fakebasecamp.World, opts ...fakebasecamp.Option) *Harness { + t.Helper() + s := fakebasecamp.Start(t, world, opts...) + home := t.TempDir() + return &Harness{t: t, Fake: s, Home: home, env: cliEnv(home, s.URL())} +} + +// cliEnv is the whole environment a CLI subprocess gets. It starts empty +// rather than from this process's, so nothing in the environment the tests +// run in (a BASECAMP_TOKEN, a BASECAMP_NONINTERACTIVE, a profile, a real +// config directory) reaches the CLI. +func cliEnv(home, baseURL string) []string { + env := []string{ + "HOME=" + home, + "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), + "XDG_STATE_HOME=" + filepath.Join(home, ".local", "state"), + "XDG_CACHE_HOME=" + filepath.Join(home, ".cache"), + "BASECAMP_NO_KEYRING=1", + "BASECAMP_NO_UPDATE_CHECK=1", + "BASECAMP_BASE_URL=" + baseURL, + "BASECAMP_OAUTH_ISSUER=" + baseURL, + } + // What the binary itself needs from the host: a PATH, a temp directory, + // and the race detector's options when the run sets them. + for _, key := range []string{"PATH", "TMPDIR", "GORACE"} { + if v, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+v) + } + } + return env +} + +// Result is how a CLI command that ran to completion ended. +type Result struct { + ExitCode int + Stdout string + Stderr string +} + +// Run runs one CLI command to completion in the harness's home and returns +// how it ended. It fails the test only when the command could not be run or +// did not finish. +func (h *Harness) Run(t *testing.T, args ...string) Result { + t.Helper() + bin := binary(t) // before the clock starts: the first build can be slow + ctx, cancel := context.WithTimeout(t.Context(), waitFor) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = h.env + cmd.Dir = h.Home + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + res := Result{Stdout: stdout.String(), Stderr: stderr.String()} + requireNoRace(t, "basecamp "+strings.Join(args, " "), res.Stderr) + var exit *exec.ExitError + switch { + case ctx.Err() != nil: + t.Fatalf("basecamp %s did not finish within %s\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), waitFor, res.Stdout, res.Stderr) + case errors.As(err, &exit): + res.ExitCode = exit.ExitCode() + case err != nil: + t.Fatalf("basecamp %s: %v", strings.Join(args, " "), err) + } + return res +} + +// MustRun runs one CLI command and requires that it succeeds. +func (h *Harness) MustRun(t *testing.T, args ...string) Result { + t.Helper() + res := h.Run(t, args...) + require.Zero(t, res.ExitCode, "basecamp %s\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), res.Stdout, res.Stderr) + return res +} + +// Setup connects the default world's agent as the Agent profile and sets +// it up to serve the default project for the operator, the way a person +// does: the connection ceremony with no browser, which the fake approves as +// soon as it is asked, then `connect setup`. +func (h *Harness) Setup(t *testing.T) { + t.Helper() + h.MustRun(t, "auth", "agent", "connect", "-P", Agent, "--no-browser") + h.MustRun(t, "connect", "setup", "-P", Agent, + "--operator", strconv.FormatInt(fakebasecamp.OperatorID, 10), + "--serve", strconv.FormatInt(fakebasecamp.ProjectID, 10), + "--json") +} + +// ConnectJSON is where `connect setup` writes the Agent profile's +// connect.json in this home. +func (h *Harness) ConnectJSON() string { + return filepath.Join(h.Home, ".config", "basecamp", "connect", Agent, "connect.json") +} + +// requireNoRace fails the test when a race-built subprocess reported a +// race. The process may still exit as it would have, so its stderr is the +// only place to see it. +func requireNoRace(t *testing.T, what, stderr string) { + t.Helper() + if strings.Contains(stderr, "WARNING: DATA RACE") { + t.Errorf("%s reported a data race:\n%s", what, stderr) + } +} diff --git a/e2e/connect/lines_test.go b/e2e/connect/lines_test.go new file mode 100644 index 000000000..db680d31d --- /dev/null +++ b/e2e/connect/lines_test.go @@ -0,0 +1,235 @@ +//go:build linux || darwin + +package connect + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The connector's two outputs, as a reader sees them. The wire types are +// written out here rather than borrowed from the connector, so a change to +// the protocol is a change this package notices. + +// Line is one line of the connector's stdout. +type Line struct { + // Raw is the line as written, without its newline. + Raw string + // Err is why the line is not a protocol line; nil for one that is. + Err error + + // Exactly one of these is set on a protocol line: intake's pointer + // (which carries no type), admission's verdict, or the handoff's + // request. + Pointer *PointerLine + Event *EventLine + Request *RequestLine +} + +// PointerLine is intake's line for an event it has newly seen. +type PointerLine struct { + EventID int64 `json:"event_id"` + EventType string `json:"event_type"` + Kind string `json:"kind"` + Action string `json:"action"` + BucketID int64 `json:"bucket_id"` + CreatorID int64 `json:"creator_id"` + PerformedByID *int64 `json:"performed_by_id"` + RecordingID int64 `json:"recording_id"` + CreatedAt string `json:"created_at"` + Lane string `json:"lane"` + State string `json:"state"` +} + +// EventLine is admission's line for a verdict. +type EventLine struct { + Type string `json:"type"` + EventID int64 `json:"event_id"` + EventType string `json:"event_type"` + Trigger string `json:"trigger"` + Class string `json:"class"` + BucketID int64 `json:"bucket_id"` + RecordingID int64 `json:"recording_id"` + RecordingURL string `json:"recording_url"` + RequesterID int64 `json:"requester_id"` + State string `json:"state"` + Reason string `json:"reason"` +} + +// RequestLine is the handoff's line for a trusted request. +type RequestLine struct { + Type string `json:"type"` + EventID int64 `json:"event_id"` + EventType string `json:"event_type"` + Trigger string `json:"trigger"` + Recording RequestRecording `json:"recording"` + ReplyTo RequestReplyTo `json:"reply_to"` + RequesterID int64 `json:"requester_id"` + RequesterName string `json:"requester_name"` + Acknowledge bool `json:"acknowledge"` + Content string `json:"content"` + // ContentUpdatedAt is a timestamp, kept as written. + ContentUpdatedAt string `json:"content_updated_at"` +} + +// RequestRecording is the recording a request is about. +type RequestRecording struct { + BucketID int64 `json:"bucket_id"` + ProjectName string `json:"project_name"` + RecordingID int64 `json:"recording_id"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` +} + +// RequestReplyTo is where a request's acknowledgement and reply go. +type RequestReplyTo struct { + Kind string `json:"kind"` + RecordingID int64 `json:"recording_id"` +} + +// parseLine reads one stdout line. A line that is not one JSON object, or +// whose type is not one the protocol has, says why in Err. +func parseLine(raw string) Line { + l := Line{Raw: raw} + var head struct { + Type *string `json:"type"` + } + if err := strictUnmarshal(raw, &head); err != nil { + l.Err = err + return l + } + switch { + case head.Type == nil: + l.Pointer = &PointerLine{} + l.Err = strictUnmarshal(raw, l.Pointer) + case *head.Type == "event": + l.Event = &EventLine{} + l.Err = strictUnmarshal(raw, l.Event) + case *head.Type == "request": + l.Request = &RequestLine{} + l.Err = strictUnmarshal(raw, l.Request) + default: + l.Err = fmt.Errorf("unknown line type %q", *head.Type) + } + return l +} + +// strictUnmarshal decodes raw, which must be one JSON object and nothing +// else: json.Unmarshal refuses trailing data, and would take a null. +func strictUnmarshal(raw string, v any) error { + if !strings.HasPrefix(raw, "{") { + return errors.New("not a JSON object") + } + return json.Unmarshal([]byte(raw), v) +} + +// LogLine is one line of the connector's stderr. +type LogLine struct { + // Raw is the line as written, without its newline. + Raw string + // Level and Msg are the slog record's; both are empty for a line that + // is not a slog record. + Level string + Msg string + // Attrs are the record's other attributes, by key, unquoted. + Attrs map[string]string +} + +// parseLogLine reads one stderr line as slog's text handler writes it: +// key=value pairs separated by spaces, where a value is bare or a Go-quoted +// string. A line that does not read that way keeps only Raw. +func parseLogLine(raw string) LogLine { + l := LogLine{Raw: raw} + attrs := map[string]string{} + rest := raw + for rest != "" { + eq := strings.IndexByte(rest, '=') + if eq <= 0 || strings.ContainsAny(rest[:eq], " \"") { + return l + } + key := rest[:eq] + rest = rest[eq+1:] + var value string + if strings.HasPrefix(rest, `"`) { + quoted, err := strconv.QuotedPrefix(rest) + if err != nil { + return l + } + if value, err = strconv.Unquote(quoted); err != nil { + return l + } + rest = rest[len(quoted):] + } else { + end := strings.IndexByte(rest, ' ') + if end < 0 { + end = len(rest) + } + value, rest = rest[:end], rest[end:] + } + attrs[key] = value + rest = strings.TrimPrefix(rest, " ") + } + level, hasLevel := attrs["level"] + msg, hasMsg := attrs["msg"] + if !hasLevel || !hasMsg { + return l + } + delete(attrs, "level") + delete(attrs, "msg") + delete(attrs, "time") + l.Level, l.Msg, l.Attrs = level, msg, attrs + return l +} + +// The checks on stdout only mean something if they can fail: a line that +// is not one JSON object of a known shape is refused. +func TestParseLineRefusesWhatIsNotAProtocolLine(t *testing.T) { + for _, raw := range []string{ + ``, + `connector: running`, + `null`, + `[1]`, + `"request"`, + `{"type":"bogus","event_id":1}`, + `{"event_id":1} {"event_id":2}`, + `{"type":"request","event_id":"one"}`, + `{"event_id":1`, + } { + assert.Error(t, parseLine(raw).Err, "%q", raw) + } + + l := parseLine(`{"event_id":7,"lane":"poll","state":"seen"}`) + require.NoError(t, l.Err) + require.NotNil(t, l.Pointer) + assert.Equal(t, int64(7), l.Pointer.EventID) + assert.Equal(t, "poll", l.Pointer.Lane) + l = parseLine(`{"type":"event","event_id":8,"state":"discarded","reason":"untrusted_performer"}`) + require.NoError(t, l.Err) + require.NotNil(t, l.Event) + assert.Equal(t, "untrusted_performer", l.Event.Reason) + l = parseLine(`{"type":"request","event_id":9,"reply_to":{"kind":"comment","recording_id":3}}`) + require.NoError(t, l.Err) + require.NotNil(t, l.Request) + assert.Equal(t, RequestReplyTo{Kind: "comment", RecordingID: 3}, l.Request.ReplyTo) +} + +func TestParseLogLineReadsSlogText(t *testing.T) { + l := parseLogLine(`time=2026-10-02T13:41:50.583-04:00 level=WARN msg="the hold stands; \"release\" it" since=2026-10-02 by="Rob Z"`) + assert.Equal(t, "WARN", l.Level) + assert.Equal(t, `the hold stands; "release" it`, l.Msg) + assert.Equal(t, map[string]string{"since": "2026-10-02", "by": "Rob Z"}, l.Attrs) + + for _, raw := range []string{`panic: boom`, `WARNING: DATA RACE`, `level=INFO`, `msg="unterminated`} { + l := parseLogLine(raw) + assert.Empty(t, l.Msg, "%q", raw) + assert.Equal(t, raw, l.Raw) + } +} diff --git a/e2e/connect/main_test.go b/e2e/connect/main_test.go new file mode 100644 index 000000000..881cbb1f3 --- /dev/null +++ b/e2e/connect/main_test.go @@ -0,0 +1,83 @@ +//go:build linux || darwin + +// Package connect runs `basecamp connect` end to end: the real binary, as +// subprocesses, against the fake Basecamp in internal/connector/fakebasecamp. +// +// Every test sets the agent up the way a person does, with `basecamp auth +// agent connect` and `basecamp connect setup`, in a home of its own, and +// then runs `basecamp connect` and reads what it writes. Its stdout is read +// as the protocol it is (typed NDJSON lines), its stderr as the slog lines +// they are, both continuously, so the process never blocks on a full pipe. +// Nothing waits on a timer: a test waits for a line, or for the fake to have +// seen something, with a deadline that only bounds a failure. +// +// It runs on the platforms the connector runs on. +package connect + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" +) + +// buildDir holds the binary TestMain's run builds, removed when it ends. +var buildDir string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "basecamp-connect-e2e-") + if err != nil { + fmt.Fprintln(os.Stderr, "e2e/connect:", err) + os.Exit(1) + } + buildDir = dir + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +var ( + buildOnce sync.Once + built string + errBuild error +) + +// binary is the basecamp binary under test, built the first time a test +// asks for it: listing the tests, or running none of them, builds nothing. +// It is built the way this test binary was, with -race when this is a race +// build and with -tags dev when this is a dev build, so a race in the +// connector fails the test that provoked it. +func binary(t *testing.T) string { + t.Helper() + buildOnce.Do(func() { + built = filepath.Join(buildDir, "basecamp") + args := []string{"build", "-o", built} + if raceBuild { + args = append(args, "-race") + } + if devBuild { + args = append(args, "-tags", "dev") + } + args = append(args, "github.com/basecamp/basecamp-cli/cmd/basecamp") + // The first build under -race compiles the standard library again; + // this only bounds a build that hangs. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", args...) + if !raceBuild { + // As the release builds it. The race detector needs cgo. + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + } + if out, err := cmd.CombinedOutput(); err != nil { + errBuild = fmt.Errorf("go %v: %w\n%s", args, err, out) + } + }) + if errBuild != nil { + t.Fatal(errBuild) + } + return built +} diff --git a/e2e/connect/race_tag_stub_test.go b/e2e/connect/race_tag_stub_test.go new file mode 100644 index 000000000..aa90b34de --- /dev/null +++ b/e2e/connect/race_tag_stub_test.go @@ -0,0 +1,5 @@ +//go:build (linux || darwin) && !race + +package connect + +const raceBuild = false diff --git a/e2e/connect/race_tag_test.go b/e2e/connect/race_tag_test.go new file mode 100644 index 000000000..762752aa1 --- /dev/null +++ b/e2e/connect/race_tag_test.go @@ -0,0 +1,7 @@ +//go:build (linux || darwin) && race + +package connect + +// raceBuild says this test binary was built with -race, so the basecamp +// binary it runs is built with it too. +const raceBuild = true