- Update roadmap milestone status - Add gitosync runner and tests - Update gitoevents tests - Adjust docker-compose configuration - Update core and project READMEs - Add agent-task for consumer wire readiness
387 lines
11 KiB
Go
387 lines
11 KiB
Go
package gitosync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
|
|
"github.com/nomadcode/nomadcode-core/internal/protosocket"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
"nhooyr.io/websocket"
|
|
)
|
|
|
|
// branchUpdatedEnvelope builds a contract-shaped broadcast envelope fixture.
|
|
func branchUpdatedEnvelope() protosocket.Envelope {
|
|
return protosocket.Envelope{
|
|
ProtocolVersion: protosocket.ProtocolVersion,
|
|
ID: "event-id",
|
|
Type: "event",
|
|
Channel: "event",
|
|
Action: "branch.updated",
|
|
Payload: map[string]any{
|
|
"id": "event-id",
|
|
"type": "branch.updated",
|
|
"provider": "forgejo",
|
|
"delivery_id": "delivery-id",
|
|
"repo_id": "nomadcode",
|
|
"branch": "develop",
|
|
"before": "old-sha",
|
|
"after": "new-sha",
|
|
"changed_files": []any{
|
|
map[string]any{
|
|
"path": "agent-roadmap/phase/example/milestones/example.md",
|
|
"change_type": "modified",
|
|
},
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"change_type": "modified",
|
|
},
|
|
},
|
|
"observed_at": "2026-06-13T00:00:00Z",
|
|
"created_at": "2026-06-13T00:00:00Z",
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestProtoSocketTransportConnectAndClose verifies basic connect/close using toki.WsClient.
|
|
func TestProtoSocketTransportConnectAndClose(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusInternalError, "")
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
_, _, err := conn.Read(ctx)
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
|
|
transport := &protoSocketTransport{url: wsURL}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
if err := transport.Connect(ctx); err != nil {
|
|
t.Fatalf("Connect failed: %v", err)
|
|
}
|
|
|
|
if transport.client == nil || !transport.client.IsAlive() {
|
|
t.Fatal("client should be alive")
|
|
}
|
|
|
|
if err := transport.Close(); err != nil {
|
|
t.Fatalf("Close failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestProtoSocketTransportOverflowAndDisconnect implements REVIEW_API-1.
|
|
// It proves backpressure on overflow and that close/disconnect terminal signals
|
|
// are not silently dropped.
|
|
func TestProtoSocketTransportOverflowAndDisconnect(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
|
|
defer serverClient.Close()
|
|
|
|
// Send 3 events.
|
|
// Since buffer capacity is 1, the client will block (backpressure) during the second or third send.
|
|
eventEnv := branchUpdatedEnvelope()
|
|
eventStruct, _ := eventEnv.ToStruct()
|
|
|
|
_ = serverClient.Send(eventStruct)
|
|
_ = serverClient.Send(eventStruct)
|
|
_ = serverClient.Send(eventStruct)
|
|
|
|
// Wait briefly then close the server-side client to trigger a disconnect
|
|
time.Sleep(100 * time.Millisecond)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
|
|
transport := &protoSocketTransport{
|
|
url: wsURL,
|
|
inboundCap: 1, // Set small buffer capacity to test overflow/backpressure
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := transport.Connect(ctx); err != nil {
|
|
t.Fatalf("Connect failed: %v", err)
|
|
}
|
|
defer transport.Close()
|
|
|
|
// Wait to let server send events and then disconnect
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// We expect to read events successfully, and finally observe the disconnect EOF without getting stuck.
|
|
// 1st Read
|
|
env1, err := transport.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Read 1 failed: %v", err)
|
|
}
|
|
if env1.Action != "branch.updated" {
|
|
t.Errorf("expected branch.updated event, got: %s", env1.Action)
|
|
}
|
|
|
|
// 2nd Read
|
|
env2, err := transport.Read(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Read 2 failed: %v", err)
|
|
}
|
|
if env2.Action != "branch.updated" {
|
|
t.Errorf("expected branch.updated event, got: %s", env2.Action)
|
|
}
|
|
|
|
// 3rd Read (Could either be 3rd event or EOF, depending on toki read timing, but must return either)
|
|
_, err = transport.Read(ctx)
|
|
// Eventually it must return EOF. We do another read just to ensure EOF.
|
|
_, err = transport.Read(ctx)
|
|
if err == nil {
|
|
t.Fatal("expected terminal EOF error, but got nil")
|
|
}
|
|
if !errors.Is(err, io.EOF) && !strings.Contains(err.Error(), "context") {
|
|
t.Fatalf("expected EOF error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent implements REVIEW_API-2.
|
|
// It verifies that gitoevents.Client.Run works with the production transport and mock server.
|
|
func TestProtoSocketTransportClientRunDeliversBinaryBranchUpdatedEvent(t *testing.T) {
|
|
subscribeCh := make(chan *structpb.Struct, 1)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
|
|
defer serverClient.Close()
|
|
|
|
toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
|
|
env, err := protosocket.EnvelopeFromStruct(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if env.Channel == "event" && env.Action == "event.subscribe" {
|
|
subscribeCh <- req
|
|
|
|
resEnv := protosocket.Envelope{
|
|
ProtocolVersion: protosocket.ProtocolVersion,
|
|
ID: "res-id",
|
|
CorrelationID: env.ID,
|
|
Type: "response",
|
|
Channel: "event",
|
|
Action: "event.subscribe",
|
|
}
|
|
resStruct, _ := resEnv.ToStruct()
|
|
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
eventEnv := branchUpdatedEnvelope()
|
|
eventStruct, _ := eventEnv.ToStruct()
|
|
_ = serverClient.Send(eventStruct)
|
|
}()
|
|
|
|
return resStruct, nil
|
|
}
|
|
return nil, errors.New("unexpected request")
|
|
})
|
|
|
|
<-r.Context().Done()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
|
|
transport := &protoSocketTransport{url: wsURL}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
handlerCh := make(chan gitoevents.BranchUpdatedEvent, 1)
|
|
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
|
|
handlerCh <- ev
|
|
}
|
|
|
|
client, err := gitoevents.NewClient(transport, gitoevents.Options{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
Handler: handler,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient failed: %v", err)
|
|
}
|
|
|
|
runErrCh := make(chan error, 1)
|
|
go func() {
|
|
runErrCh <- client.Run(ctx)
|
|
}()
|
|
|
|
// Verify subscribe request was received on the server
|
|
select {
|
|
case reqStruct := <-subscribeCh:
|
|
env, err := protosocket.EnvelopeFromStruct(reqStruct)
|
|
if err != nil {
|
|
t.Fatalf("invalid subscribe request: %v", err)
|
|
}
|
|
if env.Channel != "event" || env.Action != "event.subscribe" {
|
|
t.Fatalf("unexpected subscribe request: %+v", env)
|
|
}
|
|
events, ok := env.Payload["events"].([]any)
|
|
if !ok || len(events) != 1 || events[0] != "branch.updated" {
|
|
t.Fatalf("unexpected subscribe request events: %v", env.Payload["events"])
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for subscribe request")
|
|
}
|
|
|
|
// Verify event was received by the handler
|
|
select {
|
|
case ev := <-handlerCh:
|
|
if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" {
|
|
t.Fatalf("unexpected branch updated event: %+v", ev)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for branch updated event")
|
|
}
|
|
|
|
// Cancel context and verify client exits cleanly
|
|
cancel()
|
|
select {
|
|
case runErr := <-runErrCh:
|
|
if runErr != nil {
|
|
t.Fatalf("expected nil error on clean exit, got: %v", runErr)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for Client.Run to exit")
|
|
}
|
|
}
|
|
|
|
// TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs implements REVIEW_API-3.
|
|
// It verifies that malformed or mismatched structpb.Struct envelopes are safely ignored
|
|
// without causing handler side effects or breaking the stream.
|
|
func TestProtoSocketTransportClientRunDropsMalformedAndMismatchedStructs(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
serverClient := toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
|
|
defer serverClient.Close()
|
|
|
|
toki.AddRequestListenerTyped[*structpb.Struct, *structpb.Struct](&serverClient.Communicator, func(req *structpb.Struct) (*structpb.Struct, error) {
|
|
env, _ := protosocket.EnvelopeFromStruct(req)
|
|
if env.Action == "event.subscribe" {
|
|
resEnv := protosocket.Envelope{
|
|
ProtocolVersion: protosocket.ProtocolVersion,
|
|
ID: "res-id",
|
|
CorrelationID: env.ID,
|
|
Type: "response",
|
|
Channel: "event",
|
|
Action: "event.subscribe",
|
|
}
|
|
resStruct, _ := resEnv.ToStruct()
|
|
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// 1. Wrong channel/action
|
|
wrongEnv := branchUpdatedEnvelope()
|
|
wrongEnv.Channel = "wrong"
|
|
wrongEnv.Action = "wrong"
|
|
wrongStruct, _ := wrongEnv.ToStruct()
|
|
_ = serverClient.Send(wrongStruct)
|
|
|
|
// 2. Missing payload
|
|
missingEnv := branchUpdatedEnvelope()
|
|
missingEnv.Payload = nil
|
|
missingStruct, _ := missingEnv.ToStruct()
|
|
_ = serverClient.Send(missingStruct)
|
|
|
|
// 3. Malformed changed_files
|
|
malformedEnv := branchUpdatedEnvelope()
|
|
malformedEnv.Payload["changed_files"] = "not-a-list"
|
|
malformedStruct, _ := malformedEnv.ToStruct()
|
|
_ = serverClient.Send(malformedStruct)
|
|
|
|
// Wait a bit to ensure Client.handleEnvelope processes drops
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// 4. Send valid target event
|
|
validEnv := branchUpdatedEnvelope()
|
|
validStruct, _ := validEnv.ToStruct()
|
|
_ = serverClient.Send(validStruct)
|
|
}()
|
|
|
|
return resStruct, nil
|
|
}
|
|
return nil, errors.New("unexpected request")
|
|
})
|
|
|
|
<-r.Context().Done()
|
|
}))
|
|
defer srv.Close()
|
|
|
|
wsURL := strings.Replace(srv.URL, "http", "ws", 1)
|
|
transport := &protoSocketTransport{url: wsURL}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
handlerCh := make(chan gitoevents.BranchUpdatedEvent, 10)
|
|
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
|
|
handlerCh <- ev
|
|
}
|
|
|
|
client, err := gitoevents.NewClient(transport, gitoevents.Options{
|
|
RepoID: "nomadcode",
|
|
Branch: "develop",
|
|
Handler: handler,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient failed: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
_ = client.Run(ctx)
|
|
}()
|
|
|
|
// We expect exactly one event (the valid on-target one) to reach the handler.
|
|
select {
|
|
case ev := <-handlerCh:
|
|
if ev.RepoID != "nomadcode" || ev.Branch != "develop" {
|
|
t.Fatalf("unexpected event: %+v", ev)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for valid event")
|
|
}
|
|
|
|
// Verify no other events were received
|
|
select {
|
|
case extra := <-handlerCh:
|
|
t.Fatalf("received unexpected extra event: %+v", extra)
|
|
default:
|
|
// success
|
|
}
|
|
}
|