You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR 0 of a stack of 8 that tests basecamp connect end to end. This one adds the fake Basecamp the others run against. It changes no behaviour of the CLI.
Why
basecamp connect has good unit tests, but nothing runs the real binary against a Basecamp that speaks both lanes of the event feed. Each command test brings its own small mock (connectSetupServer, connectAS, the feed servers in the connector tests), and none of them has a cable. So a connector that starts, connects, admits and prints a request line has never been tested as one process.
The later PRs need one fake that can:
serve a real basecamp connect subprocess: the agent connection ceremony, client_credentials tokens, the account reads, the stream ticket, the poll lane and the Action Cable socket, and the recording reads admission makes;
say exactly what each lane serves, so a catch-up, a gap or a duplicate is a deterministic scenario and not a race;
break on purpose: token 429 / 5xx / invalid_client, a dropped cable, silent pings, reads that fail N times, reads held open;
express every knob of connectSetupServer, so PR 1 can move those tests onto it and delete the mock.
What
internal/connector/fakebasecamp, a normal package (not a test file) so command tests, connector tests and a subprocess harness can all share it. It takes a small Reporter interface (the part of testing.TB it needs), so it imports no test library.
One world, one lock. A World holds the account, people, projects, recordings, the agents' OAuth clients and the bearer tokens. Every request is answered from it under one mutex, and tests change it with Update. Access follows Basecamp: a token names a person, and a person sees what their projects hold. Anything else answers 404, on the reads and on both lanes.
Lanes are published explicitly.Emit(ev, Live|Poll|Both) puts an event on the lanes named, and Publish adds an emitted event to another lane later. There is no clock and no lag. A live event is queued to each matching subscription under the world's lock, so it is ordered against subscription confirms. Socket writes happen outside the lock, one writer per connection.
The poll lane follows the contract eventfeed checks: events always present, ascending ids, an opaque position, and a next URL that repeats the request's own filters. A page walks a fixed number of rows, so a page can be empty while next says there is more.
The cable follows eventfeed's own loopback server: the actioncable-v1-json subprotocol, welcome, confirm or reject, a ping every three seconds, and a ws:// URL on the same loopback port.
The connection ceremony hands over the approved client under a freshly rotated secret, as Basecamp does, and spends the code. Minting checks the secret, so a secret rotated away is invalid_client with no fault needed.
Faults and waits.Inject answers a route with any status or body, for N requests or until removed, optionally only for some callers (ByAgents). Gate holds a route's requests open until Release. Before runs a function as a route is reached. DropCable and StopPings act on the live connections. Every request is logged, and Await waits for a condition without sleeping: it re-checks after every change.
Strict about routes. A request no route serves is answered 404 and reported through the Reporter, so a client that starts calling something the fake does not know fails where it is seen.
go run ./e2e/fakebasecamp serves the default world on a loopback port. It prints the environment and the three commands that connect, set up and run the agent, and prints each request as it is answered. Typing mention posts a comment that mentions the agent on both lanes, and drop severs the cable. I used it to run auth agent connect, connect setup and connect by hand. A mention came out as a request line on stdout.
Dependencies.github.com/coder/websocket moves from an indirect entry in go.sum to a direct requirement. The SDK already uses it for this same purpose in its tests. The Nix vendorHash is updated with make update-nix-hash, and that build was verified.
Tests
The fake is held to the real clients, not to itself:
eventfeed in process.eventfeed.NewLive with the connector's own filters, over its real seams and WebSocket transport:
a catch-up walk across three pages, one of them emptied by the filters, with the exact since / position / filter queries asserted;
an event on both lanes, emitted while the walk is held at a gate, is delivered once;
a cable dropped while the reconnect is held at its mint delivers the missed event through the catch-up.
The cable by hand. welcome, confirm, reject of a second subscription, the push frame's exact members, filters and project visibility on the live lane, pings, and silence after StopPings.
The connection ceremony through internal/auth.ConnectAgent with the production HTTP clients, the secret rotation, a second connection invalidating the first secret, a spent code refused, and a read-only approval.
The reads through the SDK. profile (with boss), people (client, readable only by some, unknown), projects, project people, authorization.json, and Recordings().Summarize for message, comment, to-do, card and chat line, each finding the agent's mention. Also subscriptions and the recording history that admission reads.
Faults. token 429 with Retry-After: 1, 503 and invalid_client, faults that lapse after N, faults only for agents, a 200 override, gates, hooks, the poll lane's 400s, and unrouted requests being reported.
Every wait is an Await or a channel with a deadline, never a sleep. The package passes go test -race -count=20 and -race -count=10 -cpu=1,2. bin/ci is green.
Next in the stack
Move connect_setup_test.go onto the fake and delete connectSetupServer.
The subprocess harness and the happy path.
Failure scenarios that need no new knobs.
Queue callbacks in runConnect, with the held-reads backlog test.
The handoff grace knob, from the dev build tag.
A dev BC3 target running the same scenarios.
The lock message, docs, skill and evals.
Summary by cubic
Adds a fake Basecamp server for end-to-end testing of basecamp connect: the agent connection ceremony, the account reads, and both lanes of the event feed. It changes no CLI behavior.
Faking
One world under one lock answers every request; tests change it through Update.
Events are published to the live, poll, or both lanes explicitly, so catch-ups, gaps, and duplicates are deterministic scenarios.
Inject faults a route with any status or body, for N requests, optionally scoped to agents; Gate holds requests open, and DropCable / StopPings act on live connections.
Faults are also readable: Await re-checks after every change and returns ErrClosed once the fake closes.
Real clients
Held to eventfeed's live connector, internal/auth's connection ceremony, and the SDK's reads.
Stream tickets expire after their 120-second lifetime and stay replayable within it; the client_credentials mint refuses a scope wider than the client's approval.
go run ./e2e/fakebasecamp serves it interactively; coder/websocket becomes a direct dependency and the Nix vendorHash is updated.
Written for commit b520ac6. Summary will update on new commits.
internal/connector/fakebasecamp serves, on a loopback port, everything
`basecamp connect` talks to: the agent connection ceremony and its
client_credentials tokens, the account reads setup and admission make,
the stream ticket, the poll lane and the Action Cable socket. One world
under one lock answers every request; events are published per lane
explicitly, so catch-ups, gaps and duplicates are deterministic; and
faults (token 429/5xx/invalid_client, dropped cables, silent pings,
failing or held reads) are injected per route.
It is held to the real clients: eventfeed's live connector in process,
internal/auth's connection ceremony, and the SDK's reads. `go run
./e2e/fakebasecamp` serves it by hand.
coder/websocket becomes a direct dependency (the SDK already uses it for
its own loopback cable server), and the Nix vendorHash is updated.
Adds a shared fake Basecamp for future end-to-end basecamp connect tests, without changing CLI behavior.
Changes:
Implements authentication, account reads, polling, and Action Cable with deterministic scenario controls.
Adds real-client contract tests and an interactive manual runner.
Makes WebSocket support a direct dependency and updates the Nix vendor hash.
[!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.
The reason will be displayed to describe this comment to others. Learn more.
14 issues found across 17 files
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="internal/connector/fakebasecamp/faults_test.go">
<violation number="1" location="internal/connector/fakebasecamp/faults_test.go:94">
P2: The worker calls `do`, whose `require.NoError` exits only that goroutine on request errors, so the test blocks forever waiting on `answered`. Return the request error through the channel and assert from the test goroutine.</violation>
<violation number="2" location="internal/connector/fakebasecamp/faults_test.go:151">
P2: This substring check passes even if `performed_by_id` appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.
(Based on your team's feedback about structured JSON assertions.) .</violation>
</file>
<file name="internal/connector/fakebasecamp/reads_test.go">
<violation number="1" location="internal/connector/fakebasecamp/reads_test.go:36">
P2: These JSON assertions search raw text, so they can pass when `boss` or `identity.id` is missing from its expected location. Decode each response and assert the structured fields instead.
(Based on your team's feedback about structured JSON assertions.)</violation>
</file>
<file name="internal/connector/fakebasecamp/doc.go">
<violation number="1" location="internal/connector/fakebasecamp/doc.go:23">
P3: Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.</violation>
</file>
<file name="internal/connector/fakebasecamp/feed.go">
<violation number="1" location="internal/connector/fakebasecamp/feed.go:331">
P2: A valid cursor of `9223372036854775807` overflows `cursor+1`, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than `cursor` without incrementing it.</violation>
<violation number="2" location="internal/connector/fakebasecamp/feed.go:332">
P2: `WithPageSize(0)` leaves `end == start`, so every `next` URL repeats the same cursor forever; a negative value instead panics when slicing `poll[start:end]`. Clamp or reject non-positive sizes before computing the end.</violation>
</file>
<file name="internal/connector/fakebasecamp/oauth.go">
<violation number="1" location="internal/connector/fakebasecamp/oauth.go:93">
P2: Invalid client credentials receive malformed JSON: `oauthError` emits literal backslashes before the property quotes, so OAuth clients cannot decode the error. Format a valid JSON object in `oauthError` instead.</violation>
</file>
<file name="internal/connector/fakebasecamp/recordings.go">
<violation number="1" location="internal/connector/fakebasecamp/recordings.go:73">
P2: A visible recording can expose a parent title and URL from a project the caller cannot access. Include the parent only when the caller is a member of its bucket.</violation>
<violation number="2" location="internal/connector/fakebasecamp/recordings.go:193">
P2: A large positive `page` overflows these multiplications, making the loop index negative and panicking at `newest[i]`. Iterate over the slice and select entries by page, or reject pages beyond the available history before multiplying.</violation>
</file>
<file name="internal/connector/fakebasecamp/account.go">
<violation number="1" location="internal/connector/fakebasecamp/account.go:215">
P2: `projectPeople()` returns a restricted member's full profile even when `person()` would deny that caller with 403. Apply the same `ReadableBy` check before rendering roster entries so the roster cannot bypass the person-read access rule.</violation>
</file>
<file name="internal/connector/fakebasecamp/server.go">
<violation number="1" location="internal/connector/fakebasecamp/server.go:92">
P2: A zero page size leaves the poll position unchanged, while a negative size makes the page slice panic; catch-up can loop forever or abort. Clamp this option to at least one.</violation>
<violation number="2" location="internal/connector/fakebasecamp/server.go:97">
P2: `WithPingInterval(0)` and negative values reach `time.NewTicker` in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.</violation>
<violation number="3" location="internal/connector/fakebasecamp/server.go:173">
P2: A concurrent second `Close` returns as soon as it sees `closed`, before the first call finishes `srv.Close()` and `cables.Wait()`. Make later callers wait for shutdown completion.</violation>
</file>
<file name="e2e/fakebasecamp/main.go">
<violation number="1" location="e2e/fakebasecamp/main.go:137">
P2: Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.</violation>
</file>
Reply with feedback, questions, or to request a fix.
The reason will be displayed to describe this comment to others. Learn more.
P2: This substring check passes even if performed_by_id appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.
(Based on your team's feedback about structured JSON assertions.) .
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 internal/connector/fakebasecamp/faults_test.go, line 151:
<comment>This substring check passes even if `performed_by_id` appears at the wrong JSON level, so it does not validate the event shape. Decode the event JSON and assert the field's presence and value.
(Based on your team's feedback about structured JSON assertions.) .</comment>
<file context>
@@ -0,0 +1,181 @@
+ page, body := poll("since=0&exclude_performers=self")
+ require.Len(t, page.Events, 1)
+ assert.Equal(t, first.ID, page.Events[0].ID)
+ assert.Contains(t, body, `"performed_by_id":null`)
+ assert.Equal(t, s.url()+"/999/events.json?exclude_performers=self&position=fake-1", page.Next)
+
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: The worker calls do, whose require.NoError exits only that goroutine on request errors, so the test blocks forever waiting on answered. Return the request error through the channel and assert from the test goroutine.
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 internal/connector/fakebasecamp/faults_test.go, line 94:
<comment>The worker calls `do`, whose `require.NoError` exits only that goroutine on request errors, so the test blocks forever waiting on `answered`. Return the request error through the channel and assert from the test goroutine.</comment>
<file context>
@@ -0,0 +1,181 @@
+ gate := s.Gate(fakebasecamp.RouteProjects)
+ answered := make(chan int, 1)
+ go func() {
+ status, _, _ := do(t, s, http.MethodGet, "/999/projects.json", bearer, "")
+ answered <- status
+ }()
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: These JSON assertions search raw text, so they can pass when boss or identity.id is missing from its expected location. Decode each response and assert the structured fields instead.
(Based on your team's feedback about structured JSON assertions.)
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 internal/connector/fakebasecamp/reads_test.go, line 36:
<comment>These JSON assertions search raw text, so they can pass when `boss` or `identity.id` is missing from its expected location. Decode each response and assert the structured fields instead.
(Based on your team's feedback about structured JSON assertions.) </comment>
<file context>
@@ -0,0 +1,160 @@
+ assert.True(t, ok)
+ assert.Equal(t, fakebasecamp.AgentID, id, "the sgid names the person, as a mention's does")
+ _, _, body := do(t, s, http.MethodGet, "/999/my/profile.json", bearer, "")
+ assert.Contains(t, body, `"boss":{"id":26909558,"name":"Operator"}`)
+
+ client, err := agent.People().Get(bounded(t), 1003)
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: A valid cursor of 9223372036854775807 overflows cursor+1, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than cursor without incrementing it.
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 internal/connector/fakebasecamp/feed.go, line 331:
<comment>A valid cursor of `9223372036854775807` overflows `cursor+1`, so the search restarts at the first event and replays older events. Search for the first event whose ID is greater than `cursor` without incrementing it.</comment>
<file context>
@@ -0,0 +1,350 @@
+ cursor = n
+ }
+
+ start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })
+ end := min(start+c.s.opts.pageSize, len(poll))
+ rows := []pollRow{}
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: WithPageSize(0) leaves end == start, so every next URL repeats the same cursor forever; a negative value instead panics when slicing poll[start:end]. Clamp or reject non-positive sizes before computing the end.
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 internal/connector/fakebasecamp/feed.go, line 332:
<comment>`WithPageSize(0)` leaves `end == start`, so every `next` URL repeats the same cursor forever; a negative value instead panics when slicing `poll[start:end]`. Clamp or reject non-positive sizes before computing the end.</comment>
<file context>
@@ -0,0 +1,350 @@
+ }
+
+ start, _ := slices.BinarySearchFunc(poll, cursor+1, func(ev Event, id int64) int { return cmp.Compare(ev.ID, id) })
+ end := min(start+c.s.opts.pageSize, len(poll))
+ rows := []pollRow{}
+ at := cursor
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: A concurrent second Close returns as soon as it sees closed, before the first call finishes srv.Close() and cables.Wait(). Make later callers wait for shutdown completion.
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 internal/connector/fakebasecamp/server.go, line 173:
<comment>A concurrent second `Close` returns as soon as it sees `closed`, before the first call finishes `srv.Close()` and `cables.Wait()`. Make later callers wait for shutdown completion.</comment>
<file context>
@@ -0,0 +1,651 @@
+// it does. It is safe to call more than once.
+func (s *Server) Close() {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: WithPingInterval(0) and negative values reach time.NewTicker in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.
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 internal/connector/fakebasecamp/server.go, line 97:
<comment>`WithPingInterval(0)` and negative values reach `time.NewTicker` in the cable goroutine and panic when a cable opens. Reject nonpositive intervals while applying options.</comment>
<file context>
@@ -0,0 +1,651 @@
+// WithPingInterval sets how often the cable pings. The default is Action
+// Cable's three seconds, which is what eventfeed's staleness rule is built
+// around.
+func WithPingInterval(d time.Duration) Option { return func(o *options) { o.pingInterval = d } }
+
+// WithListener serves on l instead of a fresh loopback port.
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P2: Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.
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/fakebasecamp/main.go, line 137:
<comment>Quote the formatted message before logging so control characters in a request path cannot forge extra log entries.</comment>
<file context>
@@ -0,0 +1,151 @@
+
+func (*logReporter) Helper() {}
+
+func (*logReporter) Errorf(format string, args ...any) { log.Printf(format, args...) }
+
+func (r *logReporter) Cleanup(fn func()) {
</file context>
The reason will be displayed to describe this comment to others. Learn more.
P3: Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.
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 internal/connector/fakebasecamp/doc.go, line 23:
<comment>Nonmembers do not get 404s from the feed lanes: the poll endpoint omits inaccessible events, and the cable skips them. Distinguish those behaviors from direct reads so tests do not rely on the wrong contract.</comment>
<file context>
@@ -0,0 +1,56 @@
+//
+// Requests are authorized as Basecamp authorizes them: a bearer token names a
+// person, and a person reads what their projects hold. A project the caller
+// is not a member of, and anything in it, answers 404, on the reads and on
+// both lanes of the feed.
+//
</file context>
Suggested change
// is not a member of, and anything in it, answers 404, on the reads and on
// both lanes of the feed.
// is not a member of, and anything in it, answers 404 on direct reads;
// events from it are omitted from both lanes of the feed.
- Log a request's status before its response is written, so a client
holding its answer finds it in Requests; an upgrade logs its 101 the
same way.
- Await returns ErrClosed once the fake is closed, instead of waiting out
its context.
- Overlapping gates hold a request until every one is released.
- Requests and Before hooks get copies of Query and Form.
- The cable resolves its caller from the stream ticket, so When
predicates such as ByAgents narrow cable faults.
- Stream tickets expire after their 120-second expires_in. They stay
replayable within that window, as the SDK documents them.
- A connection holds any number of subscriptions, as Action Cable does;
an identical resubscribe answers nothing.
- The client_credentials mint honors scope, and refuses one wider than
the client's approval with invalid_scope.
- WithClock stamps events and ages tickets. Events keep a real created
time, because the handoff discards requests created more than
HandoffGrace before its run started.
- A bare poll entry (neither since nor position) stays the present page:
Basecamp reads it as since=now, and basecamp events poll sends it.
- Public declarations before private ones in server.go; the secret
rotation uses the fake's serial; the e2e server counts comment ids.
The reason will be displayed to describe this comment to others. Learn more.
1 issue found across 10 files (changes from recent commits).
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="internal/connector/fakebasecamp/recordings.go">
<violation number="1" location="internal/connector/fakebasecamp/recordings.go:20">
P3: `Assignments.AddedPersonIDs` is capped at `MaxAssignmentPages` and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.</violation>
</file>
Reply with feedback, questions, or to request a fix.
The reason will be displayed to describe this comment to others. Learn more.
P3: Assignments.AddedPersonIDs is capped at MaxAssignmentPages and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.
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 internal/connector/fakebasecamp/recordings.go, line 20:
<comment>`Assignments.AddedPersonIDs` is capped at `MaxAssignmentPages` and stops as soon as it finds the event, so this says an empty page is the only stopping condition. Describe the bounded search instead.</comment>
<file context>
@@ -15,7 +15,11 @@ import (
- // recordingEventsPage is how many history entries a page carries.
+ // recordingEventsPage is how many history entries a page carries:
+ // fifteen, the first page Basecamp's geared pagination serves. The
+ // connector walks pages until one comes back empty and never counts on
+ // the size, so the fake keeps one size for every page; a small one
+ // makes a walk past the first page cheap to set up.
</file context>
Suggested change
// connector walks pages until one comes back empty and never counts on
// the size, so the fake keeps one size for every page; a small one
// the connector searches a bounded number of pages, stopping when it
// finds the event or an empty page; it never counts on the size, so
Each skipped ping restarts the full waitFor timeout. With the normal three-second pings, a missing confirmation or event can keep next waiting until the test suite times out, instead of failing after fifteen seconds. Use one deadline for the whole call, including skipped pings.
Preserve and enforce requested device scope
internal/connector/fakebasecamp/oauth.go:71
The intake discards the requested scope, so auth agent connect --scope read against the default world hands over full. Production ConnectAgent rejects that widened handover (internal/auth/agent_connect.go:271–281), after the fake has already spent the code and rotated the secret. Store the requested scope with each device code and limit the approval to that scope. Use an injected response when a test needs an invalid widening.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 0 of a stack of 8 that tests
basecamp connectend to end. This one adds the fake Basecamp the others run against. It changes no behaviour of the CLI.Why
basecamp connecthas good unit tests, but nothing runs the real binary against a Basecamp that speaks both lanes of the event feed. Each command test brings its own small mock (connectSetupServer,connectAS, the feed servers in the connector tests), and none of them has a cable. So a connector that starts, connects, admits and prints a request line has never been tested as one process.The later PRs need one fake that can:
basecamp connectsubprocess: the agent connection ceremony, client_credentials tokens, the account reads, the stream ticket, the poll lane and the Action Cable socket, and the recording reads admission makes;connectSetupServer, so PR 1 can move those tests onto it and delete the mock.What
internal/connector/fakebasecamp, a normal package (not a test file) so command tests, connector tests and a subprocess harness can all share it. It takes a smallReporterinterface (the part oftesting.TBit needs), so it imports no test library.Worldholds the account, people, projects, recordings, the agents' OAuth clients and the bearer tokens. Every request is answered from it under one mutex, and tests change it withUpdate. Access follows Basecamp: a token names a person, and a person sees what their projects hold. Anything else answers 404, on the reads and on both lanes.Emit(ev, Live|Poll|Both)puts an event on the lanes named, andPublishadds an emitted event to another lane later. There is no clock and no lag. A live event is queued to each matching subscription under the world's lock, so it is ordered against subscription confirms. Socket writes happen outside the lock, one writer per connection.eventsalways present, ascending ids, an opaque position, and anextURL that repeats the request's own filters. A page walks a fixed number of rows, so a page can be empty whilenextsays there is more.actioncable-v1-jsonsubprotocol, welcome, confirm or reject, a ping every three seconds, and aws://URL on the same loopback port.invalid_clientwith no fault needed.Injectanswers a route with any status or body, for N requests or until removed, optionally only for some callers (ByAgents).Gateholds a route's requests open untilRelease.Beforeruns a function as a route is reached.DropCableandStopPingsact on the live connections. Every request is logged, andAwaitwaits for a condition without sleeping: it re-checks after every change.Reporter, so a client that starts calling something the fake does not know fails where it is seen.go run ./e2e/fakebasecampserves the default world on a loopback port. It prints the environment and the three commands that connect, set up and run the agent, and prints each request as it is answered. Typingmentionposts a comment that mentions the agent on both lanes, anddropsevers the cable. I used it to runauth agent connect,connect setupandconnectby hand. A mention came out as a request line on stdout.Dependencies.
github.com/coder/websocketmoves from an indirect entry in go.sum to a direct requirement. The SDK already uses it for this same purpose in its tests. The NixvendorHashis updated withmake update-nix-hash, and that build was verified.Tests
The fake is held to the real clients, not to itself:
eventfeed.NewLivewith the connector's own filters, over its real seams and WebSocket transport:since/position/ filter queries asserted;StopPings.internal/auth.ConnectAgentwith the production HTTP clients, the secret rotation, a second connection invalidating the first secret, a spent code refused, and a read-only approval.authorization.json, andRecordings().Summarizefor message, comment, to-do, card and chat line, each finding the agent's mention. Also subscriptions and the recording history that admission reads.Retry-After: 1, 503 andinvalid_client, faults that lapse after N, faults only for agents, a 200 override, gates, hooks, the poll lane's 400s, and unrouted requests being reported.Every wait is an
Awaitor a channel with a deadline, never a sleep. The package passesgo test -race -count=20and-race -count=10 -cpu=1,2.bin/ciis green.Next in the stack
connect_setup_test.goonto the fake and deleteconnectSetupServer.runConnect, with the held-reads backlog test.devbuild tag.Summary by cubic
Adds a fake Basecamp server for end-to-end testing of
basecamp connect: the agent connection ceremony, the account reads, and both lanes of the event feed. It changes no CLI behavior.Faking
Update.Injectfaults a route with any status or body, for N requests, optionally scoped to agents;Gateholds requests open, andDropCable/StopPingsact on live connections.Awaitre-checks after every change and returnsErrClosedonce the fake closes.Real clients
internal/auth's connection ceremony, and the SDK's reads.go run ./e2e/fakebasecampserves it interactively;coder/websocketbecomes a direct dependency and the NixvendorHashis updated.Written for commit b520ac6. Summary will update on new commits.