mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: agents git watch backend (#22565)
Adds real-time git status watching for workspace agents, so the frontend
can subscribe over WebSocket and show
git file changes in near real-time.
1. Subscription is scoped to a **chat** via `GET
/api/experimental/chats/{chat}/git/watch`.
2. The workspace agent automatically determines which paths to watch
based on tool calls made by the chat (and its ancestor chats).
3. Workspace agent polls subscribed repo working trees on a 30s
interval, on tools calls, and on explicit `refresh` from the client.
4. Scans are rate-limited to at most once per second.
5. Edited paths are tracked **in-memory** inside the workspace agent.
There is no database persistence — state is lost on agent restart. This
will be addresses in a future PR.
6. Messages sent over WebSocket include a full-repo snapshot (unified
diff, branch, origin). A new message is emitted only when the snapshot
changes.
This PR was implemented with AI with me closely controlling what it's
doing. The code follows a plan file that was updated continuously during
implementation. Here's the file if you'd like to see it:
[project.md](https://gist.github.com/hugodutka/8722cf80c92f8a56555f7bc595b770e2).
It reflects the current state of the PR.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -68,6 +69,526 @@ func (c *channelCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWatchChatGit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ChatWithNoWorkspaceReturns400", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test ensures that a chat with no workspace ID
|
||||
// returns a 400 error.
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
|
||||
|
||||
mCtrl = gomock.NewController(t)
|
||||
mDB = dbmock.NewMockStore(mCtrl)
|
||||
|
||||
chatID = uuid.New()
|
||||
|
||||
r = chi.NewMux()
|
||||
|
||||
api = API{
|
||||
ctx: ctx,
|
||||
Options: &Options{
|
||||
AgentInactiveDisconnectTimeout: testutil.WaitShort,
|
||||
Database: mDB,
|
||||
Logger: logger,
|
||||
DeploymentValues: &codersdk.DeploymentValues{},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Setup: Return a chat with no workspace ID.
|
||||
mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{Valid: false},
|
||||
}, nil)
|
||||
|
||||
// And: We mount the HTTP handler.
|
||||
r.With(httpmw.ExtractChatParam(mDB)).
|
||||
Get("/chats/{chat}/git/watch", api.watchChatGit)
|
||||
|
||||
// Given: We create the HTTP server.
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
// When: We make a request.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Then: We expect a 400 response.
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("UnauthorizedUsersCannotWatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test ensures that if the chat middleware returns
|
||||
// an error (e.g. unauthorized), the handler is not
|
||||
// reached.
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
|
||||
|
||||
mCtrl = gomock.NewController(t)
|
||||
mDB = dbmock.NewMockStore(mCtrl)
|
||||
|
||||
chatID = uuid.New()
|
||||
|
||||
r = chi.NewMux()
|
||||
|
||||
api = API{
|
||||
ctx: ctx,
|
||||
Options: &Options{
|
||||
AgentInactiveDisconnectTimeout: testutil.WaitShort,
|
||||
Database: mDB,
|
||||
Logger: logger,
|
||||
DeploymentValues: &codersdk.DeploymentValues{},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Setup: Return an error from the DB to simulate
|
||||
// unauthorized access.
|
||||
mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(
|
||||
database.Chat{}, sql.ErrNoRows,
|
||||
)
|
||||
|
||||
// And: We mount the HTTP handler.
|
||||
r.With(httpmw.ExtractChatParam(mDB)).
|
||||
Get("/chats/{chat}/git/watch", api.watchChatGit)
|
||||
|
||||
// Given: We create the HTTP server.
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
// When: We make a request.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Then: We expect a 404 (not found) since sql.ErrNoRows
|
||||
// is treated as a 404 by httpapi.Is404Error.
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("DisconnectedAgentRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test ensures that a chat whose workspace agent is
|
||||
// not connected returns a 400 error.
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
|
||||
|
||||
mCtrl = gomock.NewController(t)
|
||||
mDB = dbmock.NewMockStore(mCtrl)
|
||||
mCoordinator = tailnettest.NewMockCoordinator(mCtrl)
|
||||
|
||||
chatID = uuid.New()
|
||||
workspaceID = uuid.New()
|
||||
agentID = uuid.New()
|
||||
resourceID = uuid.New()
|
||||
|
||||
r = chi.NewMux()
|
||||
|
||||
api = API{
|
||||
ctx: ctx,
|
||||
Options: &Options{
|
||||
AgentInactiveDisconnectTimeout: testutil.WaitShort,
|
||||
Database: mDB,
|
||||
Logger: logger,
|
||||
DeploymentValues: &codersdk.DeploymentValues{},
|
||||
TailnetCoordinator: tailnettest.NewFakeCoordinator(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var tailnetCoordinator tailnet.Coordinator = mCoordinator
|
||||
api.TailnetCoordinator.Store(&tailnetCoordinator)
|
||||
|
||||
// Setup: Return a chat with a valid workspace ID.
|
||||
mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
}, nil)
|
||||
|
||||
// And: Return an agent that is disconnected (no
|
||||
// FirstConnectedAt).
|
||||
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Return([]database.WorkspaceAgent{{
|
||||
ID: agentID,
|
||||
ResourceID: resourceID,
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateCreated,
|
||||
}}, nil)
|
||||
|
||||
// And: Allow db2sdk.WorkspaceAgent to complete.
|
||||
mCoordinator.EXPECT().Node(gomock.Any()).Return(nil)
|
||||
|
||||
// And: We mount the HTTP handler.
|
||||
r.With(httpmw.ExtractChatParam(mDB)).
|
||||
Get("/chats/{chat}/git/watch", api.watchChatGit)
|
||||
|
||||
// Given: We create the HTTP server.
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
// When: We make a request.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
fmt.Sprintf("%s/chats/%s/git/watch", srv.URL, chatID), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Then: We expect a 400 response since the agent is
|
||||
// not connected.
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BidirectionalProxyWorks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test ensures that messages flow bidirectionally
|
||||
// between the client websocket and the agent websocket
|
||||
// through the coderd proxy.
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
|
||||
|
||||
mCtrl = gomock.NewController(t)
|
||||
mDB = dbmock.NewMockStore(mCtrl)
|
||||
mCoordinator = tailnettest.NewMockCoordinator(mCtrl)
|
||||
mAgentConn = agentconnmock.NewMockAgentConn(mCtrl)
|
||||
|
||||
chatID = uuid.New()
|
||||
workspaceID = uuid.New()
|
||||
agentID = uuid.New()
|
||||
resourceID = uuid.New()
|
||||
|
||||
r = chi.NewMux()
|
||||
|
||||
fAgentProvider = fakeAgentProvider{
|
||||
agentConn: func(ctx context.Context, aID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
|
||||
return mAgentConn, func() {}, nil
|
||||
},
|
||||
}
|
||||
|
||||
api = API{
|
||||
ctx: ctx,
|
||||
Options: &Options{
|
||||
AgentInactiveDisconnectTimeout: testutil.WaitShort,
|
||||
Database: mDB,
|
||||
Logger: logger,
|
||||
DeploymentValues: &codersdk.DeploymentValues{},
|
||||
TailnetCoordinator: tailnettest.NewFakeCoordinator(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var tailnetCoordinator tailnet.Coordinator = mCoordinator
|
||||
api.TailnetCoordinator.Store(&tailnetCoordinator)
|
||||
api.agentProvider = fAgentProvider
|
||||
|
||||
// Setup: Create a fake agent-side websocket server that
|
||||
// we can interact with.
|
||||
agentDone := make(chan struct{})
|
||||
closeAgentDone := sync.OnceFunc(func() { close(agentDone) })
|
||||
t.Cleanup(closeAgentDone)
|
||||
agentStreamReady := make(chan *wsjson.Stream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
], 1)
|
||||
agentSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Create stream typed from the agent's perspective:
|
||||
// reads client messages, writes server messages.
|
||||
s := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
](ws, websocket.MessageText, websocket.MessageText, logger)
|
||||
agentStreamReady <- s
|
||||
// Keep the handler alive until test signals done.
|
||||
<-agentDone
|
||||
}))
|
||||
defer agentSrv.Close()
|
||||
|
||||
// And: Return a chat with a valid workspace ID.
|
||||
mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
}, nil)
|
||||
|
||||
// And: Return a connected agent.
|
||||
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Return([]database.WorkspaceAgent{{
|
||||
ID: agentID,
|
||||
ResourceID: resourceID,
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
FirstConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
LastConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
}}, nil)
|
||||
|
||||
// And: Allow db2sdk.WorkspaceAgent to complete.
|
||||
mCoordinator.EXPECT().Node(gomock.Any()).Return(nil)
|
||||
|
||||
// And: WatchGit dials our fake agent server and returns
|
||||
// the stream.
|
||||
mAgentConn.EXPECT().WatchGit(gomock.Any(), gomock.Any(), chatID).
|
||||
DoAndReturn(func(ctx context.Context, _ slog.Logger, _ uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) {
|
||||
agentURL := strings.Replace(agentSrv.URL, "http://", "ws://", 1)
|
||||
conn, resp, err := websocket.Dial(ctx, agentURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
// From coderd's perspective: reads server messages
|
||||
// from agent, writes client messages to agent.
|
||||
s := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
](conn, websocket.MessageText, websocket.MessageText, logger)
|
||||
return s, nil
|
||||
})
|
||||
// And: We mount the HTTP handler.
|
||||
r.With(httpmw.ExtractChatParam(mDB)).
|
||||
Get("/chats/{chat}/git/watch", api.watchChatGit)
|
||||
|
||||
// Given: We create the HTTP server.
|
||||
coderdSrv := httptest.NewServer(r)
|
||||
defer coderdSrv.Close()
|
||||
|
||||
// And: Dial the WebSocket as a client.
|
||||
wsURL := strings.Replace(coderdSrv.URL, "http://", "ws://", 1)
|
||||
clientConn, resp, err := websocket.Dial(ctx, fmt.Sprintf("%s/chats/%s/git/watch", wsURL, chatID), nil)
|
||||
require.NoError(t, err)
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// And: Create a client stream.
|
||||
clientStream := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
](clientConn, websocket.MessageText, websocket.MessageText, logger)
|
||||
clientCh := clientStream.Chan()
|
||||
|
||||
// And: Wait for the agent stream to be ready.
|
||||
agentStream := testutil.RequireReceive(ctx, t, agentStreamReady)
|
||||
|
||||
// Test agent → client: Send a server message from the
|
||||
// agent and verify the client receives it.
|
||||
err = agentStream.Send(codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
||||
Message: "test-changes",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverMsg := testutil.RequireReceive(ctx, t, clientCh)
|
||||
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, serverMsg.Type)
|
||||
require.Equal(t, "test-changes", serverMsg.Message)
|
||||
|
||||
// Test client → agent: Send a client message and verify
|
||||
// the agent receives it.
|
||||
agentCh := agentStream.Chan()
|
||||
err = clientStream.Send(codersdk.WorkspaceAgentGitClientMessage{
|
||||
Type: codersdk.WorkspaceAgentGitClientMessageTypeRefresh,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
clientMsg := testutil.RequireReceive(ctx, t, agentCh)
|
||||
require.Equal(t, codersdk.WorkspaceAgentGitClientMessageTypeRefresh, clientMsg.Type)
|
||||
|
||||
// Cleanup: Close the client connection to unwind the
|
||||
// proxy chain before closing the servers.
|
||||
_ = clientStream.Close(websocket.StatusNormalClosure)
|
||||
closeAgentDone()
|
||||
coderdSrv.Close()
|
||||
agentSrv.Close()
|
||||
})
|
||||
|
||||
t.Run("ClientDisconnectTearsDown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test ensures that closing the client websocket
|
||||
// causes the agent stream to be closed.
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitLong)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
|
||||
|
||||
mCtrl = gomock.NewController(t)
|
||||
mDB = dbmock.NewMockStore(mCtrl)
|
||||
mCoordinator = tailnettest.NewMockCoordinator(mCtrl)
|
||||
mAgentConn = agentconnmock.NewMockAgentConn(mCtrl)
|
||||
|
||||
chatID = uuid.New()
|
||||
workspaceID = uuid.New()
|
||||
agentID = uuid.New()
|
||||
resourceID = uuid.New()
|
||||
|
||||
r = chi.NewMux()
|
||||
|
||||
fAgentProvider = fakeAgentProvider{
|
||||
agentConn: func(ctx context.Context, aID uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
|
||||
return mAgentConn, func() {}, nil
|
||||
},
|
||||
}
|
||||
|
||||
api = API{
|
||||
ctx: ctx,
|
||||
Options: &Options{
|
||||
AgentInactiveDisconnectTimeout: testutil.WaitShort,
|
||||
Database: mDB,
|
||||
Logger: logger,
|
||||
DeploymentValues: &codersdk.DeploymentValues{},
|
||||
TailnetCoordinator: tailnettest.NewFakeCoordinator(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var tailnetCoordinator tailnet.Coordinator = mCoordinator
|
||||
api.TailnetCoordinator.Store(&tailnetCoordinator)
|
||||
api.agentProvider = fAgentProvider
|
||||
|
||||
// Setup: Create a fake agent-side websocket server.
|
||||
agentDone := make(chan struct{})
|
||||
closeAgentDone := sync.OnceFunc(func() { close(agentDone) })
|
||||
t.Cleanup(closeAgentDone)
|
||||
agentStreamReady := make(chan *wsjson.Stream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
], 1)
|
||||
agentSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
](ws, websocket.MessageText, websocket.MessageText, logger)
|
||||
agentStreamReady <- s
|
||||
// Keep the handler alive until test signals done.
|
||||
<-agentDone
|
||||
}))
|
||||
defer agentSrv.Close()
|
||||
|
||||
// And: Return a chat with a valid workspace ID.
|
||||
mDB.EXPECT().GetChatByID(gomock.Any(), chatID).Return(database.Chat{
|
||||
ID: chatID,
|
||||
OwnerID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
}, nil)
|
||||
|
||||
// And: Return a connected agent.
|
||||
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID).
|
||||
Return([]database.WorkspaceAgent{{
|
||||
ID: agentID,
|
||||
ResourceID: resourceID,
|
||||
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
|
||||
FirstConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
LastConnectedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
}}, nil)
|
||||
|
||||
// And: Allow db2sdk.WorkspaceAgent to complete.
|
||||
mCoordinator.EXPECT().Node(gomock.Any()).Return(nil)
|
||||
|
||||
// And: WatchGit dials our fake agent server.
|
||||
mAgentConn.EXPECT().WatchGit(gomock.Any(), gomock.Any(), chatID).
|
||||
DoAndReturn(func(ctx context.Context, _ slog.Logger, _ uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error) {
|
||||
agentURL := strings.Replace(agentSrv.URL, "http://", "ws://", 1)
|
||||
conn, resp, err := websocket.Dial(ctx, agentURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
s := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
](conn, websocket.MessageText, websocket.MessageText, logger)
|
||||
return s, nil
|
||||
})
|
||||
// And: We mount the HTTP handler.
|
||||
r.With(httpmw.ExtractChatParam(mDB)).
|
||||
Get("/chats/{chat}/git/watch", api.watchChatGit)
|
||||
|
||||
// Given: We create the HTTP server.
|
||||
coderdSrv := httptest.NewServer(r)
|
||||
defer coderdSrv.Close()
|
||||
|
||||
// And: Dial the WebSocket as a client.
|
||||
wsURL := strings.Replace(coderdSrv.URL, "http://", "ws://", 1)
|
||||
clientConn, resp, err := websocket.Dial(ctx, fmt.Sprintf("%s/chats/%s/git/watch", wsURL, chatID), nil)
|
||||
require.NoError(t, err)
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// And: Wait for the agent stream to be ready.
|
||||
agentStream := testutil.RequireReceive(ctx, t, agentStreamReady)
|
||||
agentCh := agentStream.Chan()
|
||||
|
||||
// And: Verify the proxy is working first by sending a
|
||||
// message from agent to client.
|
||||
err = agentStream.Send(codersdk.WorkspaceAgentGitServerMessage{
|
||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
||||
Message: "hello",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
clientDecoder := wsjson.NewDecoder[codersdk.WorkspaceAgentGitServerMessage](clientConn, websocket.MessageText, logger)
|
||||
decodeCh := clientDecoder.Chan()
|
||||
serverMsg := testutil.RequireReceive(ctx, t, decodeCh)
|
||||
require.Equal(t, "hello", serverMsg.Message)
|
||||
|
||||
// When: We close the client WebSocket.
|
||||
clientConn.Close(websocket.StatusNormalClosure, "test closing connection")
|
||||
|
||||
// Then: We expect agentCh to be closed, indicating
|
||||
// teardown propagated to the agent side.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for agent channel to close")
|
||||
|
||||
case _, ok := <-agentCh:
|
||||
require.False(t, ok, "agent channel is expected to be closed")
|
||||
}
|
||||
|
||||
// Cleanup: Close the servers in the correct order.
|
||||
closeAgentDone()
|
||||
coderdSrv.Close()
|
||||
agentSrv.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestWatchAgentContainers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user