Test basecamp connect end to end against the fake Basecamp by robzolkos · Pull Request #823 · basecamp/basecamp-cli · GitHub
Skip to content

Test basecamp connect end to end against the fake Basecamp - #823

Open
robzolkos wants to merge 1 commit into
connect-e2e/fakefrom
connect-e2e/harness
Open

robzolkos wants to merge 1 commit into
connect-e2e/fakefrom
connect-e2e/harness

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

PR 2 of a stack of 8 that tests basecamp connect end to end. Stacked on #822 (the fake Basecamp). It adds a test package and changes no behaviour of the CLI. It does not touch the fake.

Why

#822 gives us a Basecamp that speaks both lanes of the event feed. Nothing yet runs the real binary against it. The unit tests prove the parts. They do not prove that a person who runs auth agent connect, connect setup and connect gets one request line on stdout when the operator mentions the agent.

The failure scenarios that come next (PR 3), the grace knob (PR 5) and the dev BC3 target (PR 6) all need the same harness: a real basecamp connect process, its two outputs read as they are written, and waits that never sleep. This PR builds that harness and holds it to the happy path.

What

e2e/connect, a Go test package (//go:build linux || darwin, the platforms the connector runs on). It is inside go test ./..., so make test, bin/ci, the unit test job and the race shards all run it. It has no -short skip.

  • The binary. TestMain's run builds basecamp once, the first time a test needs it, so go test -list builds nothing. It is built the way the test binary was: -race when the test binary is a race build, and -tags dev when it is a dev build. Small race_tag / dev_tag test files set the constants, as internal/commands/dev_tag.go does. If a subprocess reports WARNING: DATA RACE, the test fails.
  • A home per test. NewHarness starts the fake and builds the CLI's whole environment from nothing: HOME, XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, BASECAMP_NO_KEYRING=1, BASECAMP_NO_UPDATE_CHECK=1, and BASECAMP_BASE_URL / BASECAMP_OAUTH_ISSUER at the fake. Only PATH, TMPDIR and GORACE pass through. So no BASECAMP_TOKEN, BASECAMP_NONINTERACTIVE or real config from the host can get in.
  • Setup through the CLI. Setup runs basecamp auth agent connect -P agent --no-browser, then basecamp connect setup -P agent --operator <id> --serve <id> --json. Run / MustRun run any one-shot command.
  • The connector. Connect (or Start for any command, in any directory) starts the process. Goroutines read stdout and stderr to the end the whole time, so the process never blocks on a full pipe. Stdout is parsed into typed lines: a pointer (no type), an event verdict or a request. The wire types are written out in the test rather than borrowed from the connector, so a protocol change shows up here. Stderr is parsed as slog records.
  • Waits. WaitFor(predicate) checks again each time the process writes, never on a timer. A 30s deadline only bounds a failure. If the process exits before the condition is true, the test fails at once with the transcript. WaitRunning and WaitStreaming(n) wait for connector: running and for the nth time the feed walk reaches its head. Interrupt sends SIGINT only after connector: running. Signal, Kill, Wait (exit code) and Stop (SIGINT, then exit 130) are also there.
  • Every run. At cleanup the process is killed if it is still running and then waited for. Every stdout line must be a protocol line, and no race can be reported. A failed test logs the full transcript.

Tests

  • Setup through the CLI writes connect.json with mode 0600, with the agent, the operator trust and the served project. The fake saw one connection ceremony.
  • A trusted mention gives one pointer line (lane live), one admitted event line and exactly one request line. The request line has the event id, comment.created, trigger mentioned, and the recording with its URL and project name. It also has reply_to (the parent message), the requester id and name, acknowledge: true, and the content with the agent's mention replaced by a space.
  • The same event on both lanes: it is emitted on Live and Poll, then the cable is dropped. The reconnect's catch-up walks the poll lane from before the event, and the test asserts that the fake served that walk. The result is still one pointer line, one event line and one request line. A later live mention makes sure the walk's output was all handled before the connector stops.
  • An untrusted mention (a project member who is not the operator) gives an event line discarded / untrusted_performer and no request line.
  • Stdout and stderr: every stdout line is valid JSON with a known shape. Stderr is all slog records, and it includes connector: running with the profile, account and agent.
  • The line and slog parsers have self-tests. They show that the stdout check can fail (plain text, null, arrays, unknown types, two objects on a line, a torn line).

The five scenarios run in parallel in about 3s (about 7s under -race). go test -race -count=10 ./e2e/connect passes, and also passes when it runs at the same time as -count=30 without race. bin/ci is green.


Summary by cubic

Adds an end-to-end test package that runs the real basecamp connect binary against the fake Basecamp, covering the setup and connector flow a person runs.

  • No CLI behavior changes; only a new test package, built on the fake from Add a fake Basecamp for testing basecamp connect end to end #822.
  • Tests run on Linux and macOS, in the regular test suite, with no -short skip.
  • The binary is built once per run, with the same flags as the test binary (-race, -tags dev), so a race in the connector fails its test.
  • Each test gets an isolated home environment so host config, tokens, and profiles can't leak in.
  • Waits are driven by process output or fake server state, with a deadline that only bounds a failure.

Coverage

  • Setup through the CLI (auth agent connect, connect setup) writes owner-only connect.json.
  • A trusted mention produces one pointer, one admitted event, and exactly one request line with the expected fields.
  • An event on both lanes is handed off once, including across a reconnect's catch-up walk.
  • An untrusted mention produces a discarded event line and no request.
  • Every stdout line must be valid NDJSON; stderr must be slog records.
  • The stdout and stderr parsers have self-tests, including refusal cases.

Written for commit 15e53c0. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings October 2, 2026 17:56
@github-actions github-actions Bot added the tests Tests (unit and e2e) label Oct 2, 2026
e2e/connect runs the real binary as subprocesses against
internal/connector/fakebasecamp. Each test gets a home of its own and
sets the agent up the way a person does, with `auth agent connect
--no-browser` and `connect setup`, then runs `basecamp connect` and reads
both outputs continuously: stdout as typed NDJSON (pointer, event and
request lines), stderr as slog records. Waits are on lines or on the
fake's state, with a deadline that only bounds a failure; SIGINT is sent
only after the connector says it is running.

TestMain's run builds the binary once, the way the test binary was built
(-race for a race build, -tags dev for a dev build), so a race in the
connector fails the test that provoked it.

The scenarios: setup writes connect.json; a trusted mention becomes one
request line with the right fields and the agent's mention stripped; an
event on both lanes is one pointer and one request; an untrusted mention
is an event line with untrusted_performer and no request; stdout holds
only protocol lines and stderr says the connector runs.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The stdout parser accepts malformed objects as valid protocol pointer lines.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds end-to-end coverage for running the real basecamp connect binary against the fake Basecamp.

Changes:

  • Adds isolated subprocess, output-capture, and synchronization harnesses.
  • Tests setup, trusted/untrusted mentions, lane deduplication, and output protocols.
  • Preserves race and dev build tags when building the tested binary.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
e2e/​connect/​connect_test.go Adds happy-path connector scenarios.
e2e/​connect/​connector_test.go Manages connector subprocesses and output.
e2e/​connect/​dev_tag_stub_test.go Detects non-dev builds.
e2e/​connect/​dev_tag_test.go Detects dev builds.
e2e/​connect/​harness_test.go Provides isolated fake-backed CLI harness.
e2e/​connect/​lines_test.go Parses and validates connector output.
e2e/​connect/​main_test.go Lazily builds the tested CLI binary.
e2e/​connect/​race_tag_stub_test.go Detects non-race builds.
e2e/​connect/​race_tag_test.go Detects race builds.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread e2e/connect/lines_test.go
Comment on lines +109 to +112
switch {
case head.Type == nil:
l.Pointer = &PointerLine{}
l.Err = strictUnmarshal(raw, l.Pointer)

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="e2e/connect/lines_test.go">

<violation number="1" location="e2e/connect/lines_test.go:131">
P3: `strictUnmarshal` only rejects a non-`{` prefix; `json.Unmarshal` silently ignores unknown keys and absent fields, so a line like `{\"type\":\"request\"}` or a request line with an extra/renamed field still parses as a valid protocol line with zero values. That undercuts the file's stated guarantee that a line \"that is not one JSON object of a known shape is refused\" and that protocol drift surfaces in this package. Decode the full line with a `json.Decoder` that calls `DisallowUnknownFields()`, and keep the plain head pre-decode (which only knows `type`) permissive.\n\nFix sketch:\n\n\tfunc parseLine(raw string) Line {\n\t\tl := Line{Raw: raw}\n\t\tif !strings.HasPrefix(raw, \"{\") {\n\t\t\tl.Err = errors.New(\"not a JSON object\")\n\t\t\treturn l\n\t\t}\n\t\tvar head struct {\n\t\t\tType *string `json:\"type\"`\n\t\t}\n\t\tif err := json.Unmarshal([]byte(raw), &head); err != nil {\n\t\t\tl.Err = err\n\t\t\treturn l\n\t\t}\n\t\tdecodeShape := func(v any) error {\n\t\t\tdec := json.NewDecoder(strings.NewReader(raw))\n\t\t\tdec.DisallowUnknownFields()\n\t\t\tif err := dec.Decode(v); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar extra any\n\t\t\treturn dec.Decode(&extra) // must be io.EOF for trailing-data refusal\n\t\t}\n\t\tswitch {\n\t\tcase head.Type == nil:\n\t\t\tl.Pointer = &PointerLine{}\n\t\t\tl.Err = decodeShape(l.Pointer)\n\t\tcase *head.Type == \"event\":\n\t\t\tl.Event = &EventLine{}\n\t\t\tl.Err = decodeShape(l.Event)\n\t\tcase *head.Type == \"request\":\n\t\t\tl.Request = &RequestLine{}\n\t\t\tl.Err = decodeShape(l.Request)\n\t\tdefault:\n\t\t\tl.Err = fmt.Errorf(\"unknown line type %q\", *head.Type)\n\t\t}\n\t\treturn l\n\t}</violation>
</file>

<file name="e2e/connect/connector_test.go">

<violation number="1" location="e2e/connect/connector_test.go:258">
P2: `cleanup` blocks forever on `<-c.exited` with no timeout. The exit goroutine only finishes after both pipe readers hit EOF and `cmd.Wait()` returns; if the connector ever fails to terminate (or a child it spawned inherits its stdout/stderr and keeps them open), the readers never reach EOF and the entire test binary hangs instead of failing this test. Every other wait in the harness is bounded by `waitFor`; this one should be too.</violation>

<violation number="2" location="e2e/connect/connector_test.go:261">
P3: A final line torn by SIGKILL is reported as a protocol violation. If a test uses `Kill(t)` while the connector is mid-write, `readLines` flags the unterminated last line with "the line was not ended: torn", and `cleanup` then fails the test with "stdout line N is not a protocol line" even though the test's own assertions passed. Skip the protocol check for torn lines when the process was signaled (`out.ExitCode == -1`).</violation>
</file>

<file name="e2e/connect/harness_test.go">

<violation number="1" location="e2e/connect/harness_test.go:35">
P3: `Harness.t` is never read: `NewHarness` stores the `*testing.T` in it, but every method (`Run`, `MustRun`, `Setup`) takes `t` as a parameter and nothing in the package accesses the field (the only `.t` use is `Connector.t`). Drop the field and its assignment in `NewHarness`.</violation>

<violation number="2" location="e2e/connect/harness_test.go:72">
P2: A `GORACE=log_path=...` setting redirects race reports away from stderr, but `requireNoRace` only scans stderr, so subprocess races can pass unnoticed. Force reports to stderr or inspect the configured report file.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// do: write only protocol lines to stdout, and provoke no race.
func (c *Connector) cleanup() {
t := c.t
<-c.exited

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: cleanup blocks forever on <-c.exited with no timeout. The exit goroutine only finishes after both pipe readers hit EOF and cmd.Wait() returns; if the connector ever fails to terminate (or a child it spawned inherits its stdout/stderr and keeps them open), the readers never reach EOF and the entire test binary hangs instead of failing this test. Every other wait in the harness is bounded by waitFor; this one should be too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/connector_test.go, line 258:

<comment>`cleanup` blocks forever on `<-c.exited` with no timeout. The exit goroutine only finishes after both pipe readers hit EOF and `cmd.Wait()` returns; if the connector ever fails to terminate (or a child it spawned inherits its stdout/stderr and keeps them open), the readers never reach EOF and the entire test binary hangs instead of failing this test. Every other wait in the harness is bounded by `waitFor`; this one should be too.</comment>

<file context>
@@ -0,0 +1,364 @@
+// 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 {
</file context>

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A GORACE=log_path=... setting redirects race reports away from stderr, but requireNoRace only scans stderr, so subprocess races can pass unnoticed. Force reports to stderr or inspect the configured report file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/harness_test.go, line 72:

<comment>A `GORACE=log_path=...` setting redirects race reports away from stderr, but `requireNoRace` only scans stderr, so subprocess races can pass unnoticed. Force reports to stderr or inspect the configured report file.</comment>

<file context>
@@ -0,0 +1,148 @@
+	// 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)
+		}
+	}
</file context>

Comment thread e2e/connect/lines_test.go
if !strings.HasPrefix(raw, "{") {
return errors.New("not a JSON object")
}
return json.Unmarshal([]byte(raw), v)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: strictUnmarshal only rejects a non-{ prefix; json.Unmarshal silently ignores unknown keys and absent fields, so a line like {\"type\":\"request\"} or a request line with an extra/renamed field still parses as a valid protocol line with zero values. That undercuts the file's stated guarantee that a line "that is not one JSON object of a known shape is refused" and that protocol drift surfaces in this package. Decode the full line with a json.Decoder that calls DisallowUnknownFields(), and keep the plain head pre-decode (which only knows type) permissive.\n\nFix sketch:\n\n\tfunc parseLine(raw string) Line {\n\t\tl := Line{Raw: raw}\n\t\tif !strings.HasPrefix(raw, "{") {\n\t\t\tl.Err = errors.New("not a JSON object")\n\t\t\treturn l\n\t\t}\n\t\tvar head struct {\n\t\t\tType *string json:\"type\"\n\t\t}\n\t\tif err := json.Unmarshal([]byte(raw), &head); err != nil {\n\t\t\tl.Err = err\n\t\t\treturn l\n\t\t}\n\t\tdecodeShape := func(v any) error {\n\t\t\tdec := json.NewDecoder(strings.NewReader(raw))\n\t\t\tdec.DisallowUnknownFields()\n\t\t\tif err := dec.Decode(v); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar extra any\n\t\t\treturn dec.Decode(&extra) // must be io.EOF for trailing-data refusal\n\t\t}\n\t\tswitch {\n\t\tcase head.Type == nil:\n\t\t\tl.Pointer = &PointerLine{}\n\t\t\tl.Err = decodeShape(l.Pointer)\n\t\tcase *head.Type == "event":\n\t\t\tl.Event = &EventLine{}\n\t\t\tl.Err = decodeShape(l.Event)\n\t\tcase *head.Type == "request":\n\t\t\tl.Request = &RequestLine{}\n\t\t\tl.Err = decodeShape(l.Request)\n\t\tdefault:\n\t\t\tl.Err = fmt.Errorf("unknown line type %q", *head.Type)\n\t\t}\n\t\treturn l\n\t}

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/lines_test.go, line 131:

<comment>`strictUnmarshal` only rejects a non-`{` prefix; `json.Unmarshal` silently ignores unknown keys and absent fields, so a line like `{\"type\":\"request\"}` or a request line with an extra/renamed field still parses as a valid protocol line with zero values. That undercuts the file's stated guarantee that a line \"that is not one JSON object of a known shape is refused\" and that protocol drift surfaces in this package. Decode the full line with a `json.Decoder` that calls `DisallowUnknownFields()`, and keep the plain head pre-decode (which only knows `type`) permissive.\n\nFix sketch:\n\n\tfunc parseLine(raw string) Line {\n\t\tl := Line{Raw: raw}\n\t\tif !strings.HasPrefix(raw, \"{\") {\n\t\t\tl.Err = errors.New(\"not a JSON object\")\n\t\t\treturn l\n\t\t}\n\t\tvar head struct {\n\t\t\tType *string `json:\"type\"`\n\t\t}\n\t\tif err := json.Unmarshal([]byte(raw), &head); err != nil {\n\t\t\tl.Err = err\n\t\t\treturn l\n\t\t}\n\t\tdecodeShape := func(v any) error {\n\t\t\tdec := json.NewDecoder(strings.NewReader(raw))\n\t\t\tdec.DisallowUnknownFields()\n\t\t\tif err := dec.Decode(v); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tvar extra any\n\t\t\treturn dec.Decode(&extra) // must be io.EOF for trailing-data refusal\n\t\t}\n\t\tswitch {\n\t\tcase head.Type == nil:\n\t\t\tl.Pointer = &PointerLine{}\n\t\t\tl.Err = decodeShape(l.Pointer)\n\t\tcase *head.Type == \"event\":\n\t\t\tl.Event = &EventLine{}\n\t\t\tl.Err = decodeShape(l.Event)\n\t\tcase *head.Type == \"request\":\n\t\t\tl.Request = &RequestLine{}\n\t\t\tl.Err = decodeShape(l.Request)\n\t\tdefault:\n\t\t\tl.Err = fmt.Errorf(\"unknown line type %q\", *head.Type)\n\t\t}\n\t\treturn l\n\t}</comment>

<file context>
@@ -0,0 +1,235 @@
+	if !strings.HasPrefix(raw, "{") {
+		return errors.New("not a JSON object")
+	}
+	return json.Unmarshal([]byte(raw), v)
+}
+
</file context>

<-c.exited
out := c.Output()
for i, l := range out.Lines {
if l.Err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A final line torn by SIGKILL is reported as a protocol violation. If a test uses Kill(t) while the connector is mid-write, readLines flags the unterminated last line with "the line was not ended: torn", and cleanup then fails the test with "stdout line N is not a protocol line" even though the test's own assertions passed. Skip the protocol check for torn lines when the process was signaled (out.ExitCode == -1).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/connector_test.go, line 261:

<comment>A final line torn by SIGKILL is reported as a protocol violation. If a test uses `Kill(t)` while the connector is mid-write, `readLines` flags the unterminated last line with "the line was not ended: torn", and `cleanup` then fails the test with "stdout line N is not a protocol line" even though the test's own assertions passed. Skip the protocol check for torn lines when the process was signaled (`out.ExitCode == -1`).</comment>

<file context>
@@ -0,0 +1,364 @@
+	<-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)
+		}
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Harness.t is never read: NewHarness stores the *testing.T in it, but every method (Run, MustRun, Setup) takes t as a parameter and nothing in the package accesses the field (the only .t use is Connector.t). Drop the field and its assignment in NewHarness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At e2e/connect/harness_test.go, line 35:

<comment>`Harness.t` is never read: `NewHarness` stores the `*testing.T` in it, but every method (`Run`, `MustRun`, `Setup`) takes `t` as a parameter and nothing in the package accesses the field (the only `.t` use is `Connector.t`). Drop the field and its assignment in `NewHarness`.</comment>

<file context>
@@ -0,0 +1,148 @@
+// 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
</file context>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants