oto/services/core/internal/runnersocket/server_test.go
toki dde15565a5 refactor: runner proto socket transport hardening - refactor agent components and update tests
- Move completed subtasks (02+01_dispatch_state, 03+01,02_runner_actions) to archive
- Refactor agent_runner.dart, oto_server_job_client.dart, registration_client.dart
- Update smoke tests and server tests for Go services
2026-06-20 20:47:22 +09:00

703 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package runnersocket
import (
"net"
"testing"
"time"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
)
func testServer(registry *runnerregistry.Registry) *Server {
return New("127.0.0.1:0", registry, cicdstate.NewStore())
}
// pipeClient creates a TcpClient backed by one end of a net.Pipe.
// heartbeat is disabled (intervalSec=0) to avoid background timer interference.
// Cleanup closes both ends.
func pipeClient(t *testing.T) (*protoSocket.TcpClient, net.Conn) {
t.Helper()
a, b := net.Pipe()
client := protoSocket.NewTcpClient(a, 0, 0, parserMap())
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
return client, b
}
// runnerParserMap returns a parser map for the runner (client) side of a
// proto-socket connection. It includes server-to-runner frames so tests can
// verify dispatch and cancel delivery.
func runnerParserMap() protoSocket.ParserMap {
return protoSocket.ParserMap{
protoSocket.TypeNameOf(&otopb.RunRequest{}): parserFor(func() *otopb.RunRequest { return &otopb.RunRequest{} }),
protoSocket.TypeNameOf(&otopb.CancelRunRequest{}): parserFor(func() *otopb.CancelRunRequest { return &otopb.CancelRunRequest{} }),
}
}
// pipeClientPair creates two TcpClients backed by the two ends of a net.Pipe.
// serverSide uses parserMap(); runnerSide uses runnerParserMap() so tests can
// capture and inspect RunRequest frames sent by DispatchQueuedJobs.
// Cleanup closes both connections.
func pipeClientPair(t *testing.T) (serverSide, runnerSide *protoSocket.TcpClient) {
t.Helper()
a, b := net.Pipe()
serverSide = protoSocket.NewTcpClient(a, 0, 0, parserMap())
runnerSide = protoSocket.NewTcpClient(b, 0, 0, runnerParserMap())
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
return serverSide, runnerSide
}
// TestServerSetClientReplacesDuplicate verifies that registering a second client
// for the same runner ID closes the old one and stores the new one.
func TestServerReplacesDuplicateRunnerClient(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-A", client1)
s.setClient("runner-A", client2)
// Allow Close() to propagate through connCloseOnce.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement, but it is still alive")
}
if got := s.clientFor("runner-A"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
}
// TestServerIgnoresStaleClientDisconnect verifies that removeClient ignores
// a client that is not the current entry for the given runner ID.
func TestServerIgnoresStaleClientDisconnect(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-B", client1)
// client2 is a different object simulates a stale disconnect listener.
removed := s.removeClient("runner-B", client2)
if removed {
t.Error("removeClient must return false for a client that is not the current one")
}
if got := s.clientFor("runner-B"); got != client1 {
t.Errorf("current client must still be client1 after stale removeClient; got %v", got)
}
}
// TestServerStaleClientDisconnectDoesNotDisconnectRegistry verifies the complete
// stale-disconnect protection chain:
//
// 1. setClient replaces the old client.
// 2. removeClient returns false for the stale old client.
// 3. Because removeClient returns false, registry.Disconnect is NOT called,
// so the runner remains in a healthy (non-disconnected) state.
func TestServerStaleClientDisconnectDoesNotDisconnectRegistry(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-C",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
registry.Heartbeat("runner-C", otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
s.setClient("runner-C", client1)
// Duplicate connection: client2 replaces client1.
s.setClient("runner-C", client2)
// Simulate stale client1's disconnect listener.
removed := s.removeClient("runner-C", client1)
if removed {
t.Error("stale removeClient should return false")
}
// Because removeClient returned false the disconnect listener guard skips
// registry.Disconnect. The runner must NOT be in disconnected state.
runner, ok := registry.Snapshot("runner-C")
if !ok {
t.Fatal("runner-C not found in registry")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner status must not be %q after stale disconnect; got %q",
runnerregistry.StatusDisconnected, runner.Status)
}
if got := s.clientFor("runner-C"); got != client2 {
t.Errorf("current client must still be client2; got %v", got)
}
}
// TestServerDuplicateReplaceWithListenerDoesNotDeadlock verifies that replacing
// a client that carries a production-style disconnect listener does not deadlock.
//
// Before the fix, setClient held s.mu while calling old.Close(), which fired
// the listener synchronously. The listener called removeClient → tried to
// acquire s.mu again → deadlock. After the fix, old.Close() is called after
// s.mu is released, so the re-entry is safe.
func TestServerDuplicateReplaceWithListenerDoesNotDeadlock(t *testing.T) {
registry := runnerregistry.New()
s := testServer(registry)
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: "runner-E",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
}
client1, _ := pipeClient(t)
client2, _ := pipeClient(t)
// Mirror the production disconnect listener from attachClientHandlers.
// This is the re-entry path: removeClient acquires s.mu, which would
// deadlock if setClient still held s.mu during old.Close().
client1.AddDisconnectListener(func(c *protoSocket.TcpClient) {
if s.removeClient("runner-E", c) {
s.registry.Disconnect("runner-E")
}
})
s.setClient("runner-E", client1)
// This must complete without hanging. If the lock-order bug is present the
// goroutine stalls here and the test times out via go test -timeout.
s.setClient("runner-E", client2)
// Allow Close() and the disconnect listener to propagate.
time.Sleep(20 * time.Millisecond)
if client1.IsAlive() {
t.Error("old client must be closed after replacement")
}
if got := s.clientFor("runner-E"); got != client2 {
t.Errorf("clientFor must return new client after replacement; got %v", got)
}
// The stale listener must not have disconnected the runner.
runner, ok := registry.Snapshot("runner-E")
if !ok {
t.Fatal("runner-E not found in registry after replacement")
}
if runner.Status == runnerregistry.StatusDisconnected {
t.Errorf("runner must not be disconnected after stale listener fires; got %q", runner.Status)
}
}
// testServerWithStore creates a Server sharing the given registry and returns
// the associated Store for state inspection in tests.
func testServerWithStore(registry *runnerregistry.Registry) (*Server, *cicdstate.Store) {
store := cicdstate.NewStore()
return New("127.0.0.1:0", registry, store), store
}
// registerRunner adds a runner to the registry with default test fields.
func registerRunner(t *testing.T, registry *runnerregistry.Registry, runnerID string) {
t.Helper()
resp := registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "tok",
RunnerId: runnerID,
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
})
if !resp.GetAccepted() {
t.Fatalf("registerRunner %s: rejected: %s", runnerID, resp.GetRejectReason())
}
}
// createQueuedJob adds a job with a valid inline YAML to the store.
func createQueuedJob(t *testing.T, store *cicdstate.Store, jobID string) {
t.Helper()
if _, err := store.CreateJob(jobID, jobID, &cicdstate.RunInput{PipelineYAML: "pipeline: test"}); err != nil {
t.Fatalf("CreateJob %s: %v", jobID, err)
}
}
// setupRunningExecution creates a queued job, registers the runner, creates an
// execution, and transitions both job and execution to running. Returns execID.
func setupRunningExecution(t *testing.T, store *cicdstate.Store, registry *runnerregistry.Registry, jobID, runnerID string) string {
t.Helper()
createQueuedJob(t, store, jobID)
registerRunner(t, registry, runnerID)
execID := store.NextExecutionID()
if _, err := store.CreateExecution(jobID, execID); err != nil {
t.Fatalf("CreateExecution: %v", err)
}
_ = store.SetExecutionRunnerID(execID, runnerID)
if err := store.TransitionJob(jobID, cicdstate.StateRunning); err != nil {
t.Fatalf("TransitionJob running: %v", err)
}
if err := store.TransitionExecution(execID, cicdstate.StateRunning); err != nil {
t.Fatalf("TransitionExecution running: %v", err)
}
return execID
}
// TestServerDispatchQueuedJobOverSocket verifies that DispatchQueuedJobs
// transitions the queued job and its execution to running over the socket
// transport, assigns the dispatched runner ID to the execution, and sends a
// RunRequest frame whose fields match the store state.
func TestServerDispatchQueuedJobOverSocket(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
registerRunner(t, registry, "runner-dispatch")
jobID := "job-dispatch"
if _, err := store.CreateJob(jobID, jobID, &cicdstate.RunInput{
PipelineYAML: "pipeline: dispatch-test",
Variables: map[string]string{"ENV": "ci"},
CommandTypes: []string{"flutter"},
}); err != nil {
t.Fatalf("CreateJob: %v", err)
}
// Use a runner-side TcpClient so we can parse the RunRequest frame.
serverClient, runnerClient := pipeClientPair(t)
received := make(chan *otopb.RunRequest, 1)
protoSocket.AddListenerTyped[*otopb.RunRequest](&runnerClient.Communicator, func(req *otopb.RunRequest) {
select {
case received <- req:
default:
}
})
s.setClient("runner-dispatch", serverClient)
s.DispatchQueuedJobs()
var rr *otopb.RunRequest
select {
case rr = <-received:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for RunRequest")
}
job, err := store.GetJob(jobID)
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.State != cicdstate.StateRunning {
t.Errorf("job state: want %s, got %s", cicdstate.StateRunning, job.State)
}
if job.ExecutionID == "" {
t.Fatal("no execution created for dispatched job")
}
exec, err := store.GetExecution(job.ExecutionID)
if err != nil {
t.Fatalf("GetExecution: %v", err)
}
if exec.State != cicdstate.StateRunning {
t.Errorf("execution state: want %s, got %s", cicdstate.StateRunning, exec.State)
}
if exec.RunnerID != "runner-dispatch" {
t.Errorf("execution runnerID: want runner-dispatch, got %s", exec.RunnerID)
}
// Verify RunRequest payload fields match the store state.
if rr.RunnerId != "runner-dispatch" {
t.Errorf("RunRequest.RunnerId: want runner-dispatch, got %s", rr.RunnerId)
}
if rr.JobId != jobID {
t.Errorf("RunRequest.JobId: want %s, got %s", jobID, rr.JobId)
}
if rr.ExecutionId != job.ExecutionID {
t.Errorf("RunRequest.ExecutionId: want %s, got %s", job.ExecutionID, rr.ExecutionId)
}
if rr.PipelineYaml != "pipeline: dispatch-test" {
t.Errorf("RunRequest.PipelineYaml: want %q, got %q", "pipeline: dispatch-test", rr.PipelineYaml)
}
if len(rr.Variables) == 0 || rr.Variables["ENV"] != "ci" {
t.Errorf("RunRequest.Variables: want {ENV:ci}, got %v", rr.Variables)
}
if len(rr.CommandTypes) != 1 || rr.CommandTypes[0] != "flutter" {
t.Errorf("RunRequest.CommandTypes: want [flutter], got %v", rr.CommandTypes)
}
}
// TestServerCancelExecutionSendsSocketCancelToRunner verifies the server-side
// cancel action uses the runner-facing socket as a best-effort signal channel.
func TestServerCancelExecutionSendsSocketCancelToRunner(t *testing.T) {
s := testServer(runnerregistry.New())
serverClient, runnerClient := pipeClientPair(t)
received := make(chan *otopb.CancelRunRequest, 1)
protoSocket.AddListenerTyped[*otopb.CancelRunRequest](&runnerClient.Communicator, func(req *otopb.CancelRunRequest) {
select {
case received <- req:
default:
}
})
s.setClient("runner-cancel", serverClient)
if err := s.CancelExecution("runner-cancel", "exec-cancel", "operator request"); err != nil {
t.Fatalf("CancelExecution: %v", err)
}
var req *otopb.CancelRunRequest
select {
case req = <-received:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for CancelRunRequest")
}
if req.RunnerId != "runner-cancel" {
t.Errorf("CancelRunRequest.RunnerId: want runner-cancel, got %s", req.RunnerId)
}
if req.ExecutionId != "exec-cancel" {
t.Errorf("CancelRunRequest.ExecutionId: want exec-cancel, got %s", req.ExecutionId)
}
if req.Reason != "operator request" {
t.Errorf("CancelRunRequest.Reason: want operator request, got %s", req.Reason)
}
if err := s.CancelExecution("runner-missing", "exec-cancel", "operator request"); err == nil {
t.Fatal("expected missing connected runner to return an error")
}
}
// TestServerDispatchClaimsOnlyOneJobPerRunner verifies that a single
// DispatchQueuedJobs call dispatches at most one job per runner, leaving any
// remaining queued jobs untouched.
func TestServerDispatchClaimsOnlyOneJobPerRunner(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
registerRunner(t, registry, "runner-claim")
createQueuedJob(t, store, "job-claim-1")
createQueuedJob(t, store, "job-claim-2")
client, remote := pipeClient(t)
// Drain the remote end so Send does not block.
go func() {
buf := make([]byte, 4096)
for {
if _, err := remote.Read(buf); err != nil {
return
}
}
}()
s.setClient("runner-claim", client)
s.DispatchQueuedJobs()
job1, err := store.GetJob("job-claim-1")
if err != nil {
t.Fatalf("GetJob job-claim-1: %v", err)
}
job2, err := store.GetJob("job-claim-2")
if err != nil {
t.Fatalf("GetJob job-claim-2: %v", err)
}
running, queued := 0, 0
for _, j := range []*cicdstate.Job{job1, job2} {
switch j.State {
case cicdstate.StateRunning:
running++
case cicdstate.StateQueued:
queued++
}
}
if running != 1 {
t.Errorf("want exactly 1 running job after one dispatch pass, got %d", running)
}
if queued != 1 {
t.Errorf("want exactly 1 queued job after one dispatch pass, got %d", queued)
}
}
// TestServerFailsExecutionWhenRunRequestCannotBeSent verifies that a send
// error over the socket transitions the claimed execution and job to failed
// rather than leaving them stuck in running.
func TestServerFailsExecutionWhenRunRequestCannotBeSent(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
registerRunner(t, registry, "runner-sendfail")
createQueuedJob(t, store, "job-sendfail")
client, remote := pipeClient(t)
// Close the remote end before dispatch so any write to the client fails.
_ = remote.Close()
s.setClient("runner-sendfail", client)
s.DispatchQueuedJobs()
job, err := store.GetJob("job-sendfail")
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.State != cicdstate.StateFailed {
t.Errorf("job state: want %s, got %s", cicdstate.StateFailed, job.State)
}
if job.ExecutionID == "" {
t.Fatal("expected execution to be created before send failure")
}
exec, err := store.GetExecution(job.ExecutionID)
if err != nil {
t.Fatalf("GetExecution: %v", err)
}
if exec.State != cicdstate.StateFailed {
t.Errorf("execution state: want %s, got %s", cicdstate.StateFailed, exec.State)
}
}
// TestServerReportsRunResultOverSocket verifies that handleExecutionReport
// accepts success and failure terminal reports from the owning runner and
// transitions execution and job to the correct terminal state.
func TestServerReportsRunResultOverSocket(t *testing.T) {
cases := []struct {
name string
success bool
wantState string
}{
{"success", true, cicdstate.StateSucceeded},
{"failure", false, cicdstate.StateFailed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
jobID := "job-report-" + tc.name
runnerID := "runner-report-" + tc.name
execID := setupRunningExecution(t, store, registry, jobID, runnerID)
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
Success: tc.success,
})
if !resp.GetAccepted() {
t.Fatalf("report rejected: %s", resp.GetErrorMessage())
}
exec, err := store.GetExecution(execID)
if err != nil {
t.Fatalf("GetExecution: %v", err)
}
if exec.State != tc.wantState {
t.Errorf("execution state: want %s, got %s", tc.wantState, exec.State)
}
job, err := store.GetJob(jobID)
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.State != tc.wantState {
t.Errorf("job state: want %s, got %s", tc.wantState, job.State)
}
})
}
}
// TestServerRejectsTerminalReportForInactiveExecution verifies the active
// execution gate: terminal reports on queued, succeeded, or cancelled
// executions are rejected, and reports from the wrong runner are rejected.
func TestServerRejectsTerminalReportForInactiveExecution(t *testing.T) {
t.Run("queued_execution", func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
registerRunner(t, registry, "runner-guard")
createQueuedJob(t, store, "job-guard-queued")
execID := store.NextExecutionID()
if _, err := store.CreateExecution("job-guard-queued", execID); err != nil {
t.Fatalf("CreateExecution: %v", err)
}
_ = store.SetExecutionRunnerID(execID, "runner-guard")
// Execution remains in queued state; terminal report must be rejected.
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: "runner-guard", JobId: "job-guard-queued", ExecutionId: execID, Success: true,
})
if resp.GetAccepted() {
t.Error("expected rejection for queued execution, got accepted")
}
})
t.Run("succeeded_execution", func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
execID := setupRunningExecution(t, store, registry, "job-guard-succ", "runner-guard-succ")
_ = store.TransitionExecution(execID, cicdstate.StateSucceeded)
_ = store.TransitionJob("job-guard-succ", cicdstate.StateSucceeded)
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: "runner-guard-succ", JobId: "job-guard-succ", ExecutionId: execID, Success: true,
})
if resp.GetAccepted() {
t.Error("expected rejection for succeeded execution, got accepted")
}
})
t.Run("cancelled_execution", func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
execID := setupRunningExecution(t, store, registry, "job-guard-cancel", "runner-guard-cancel")
if err := store.CancelJobExecution("job-guard-cancel", execID); err != nil {
t.Fatalf("CancelJobExecution: %v", err)
}
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: "runner-guard-cancel", JobId: "job-guard-cancel", ExecutionId: execID, Success: true,
})
if resp.GetAccepted() {
t.Error("expected rejection for cancelled execution, got accepted")
}
})
t.Run("wrong_runner", func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
execID := setupRunningExecution(t, store, registry, "job-guard-runner", "runner-owner")
registerRunner(t, registry, "runner-other")
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: "runner-other", JobId: "job-guard-runner", ExecutionId: execID, Success: true,
})
if resp.GetAccepted() {
t.Error("expected rejection for wrong runner, got accepted")
}
if resp.GetErrorMessage() == "" {
t.Error("expected non-empty error message for wrong runner")
}
})
}
// TestServerDispatchInvalidRunInputFails verifies that DispatchQueuedJobs
// transitions a job with an invalid RunInput directly to failed without creating
// an execution. Two invalid fixture variants are covered: both fields empty and
// both fields set simultaneously.
func TestServerDispatchInvalidRunInputFails(t *testing.T) {
cases := []struct {
name string
input *cicdstate.RunInput
}{
{
"both_empty",
&cicdstate.RunInput{PipelineYAML: "", PipelineYAMLPath: ""},
},
{
"both_set",
&cicdstate.RunInput{PipelineYAML: "pipeline: test", PipelineYAMLPath: "/some/path.yaml"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
runnerID := "runner-invalid-" + tc.name
jobID := "job-invalid-" + tc.name
registerRunner(t, registry, runnerID)
if _, err := store.CreateJob(jobID, jobID, tc.input); err != nil {
t.Fatalf("CreateJob: %v", err)
}
// No drain needed: validateRunInput fails before client.Send is called.
client, _ := pipeClient(t)
s.setClient(runnerID, client)
s.DispatchQueuedJobs()
job, err := store.GetJob(jobID)
if err != nil {
t.Fatalf("GetJob: %v", err)
}
if job.State != cicdstate.StateFailed {
t.Errorf("job state: want %s, got %s", cicdstate.StateFailed, job.State)
}
if job.ExecutionID != "" {
t.Errorf("no execution should be created for invalid run input; got execID %s", job.ExecutionID)
}
})
}
}
// TestServerReportMessageSavedToLog verifies that a non-empty Message in an
// ExecutionReportRequest is appended to the execution log after the report is
// accepted.
func TestServerReportMessageSavedToLog(t *testing.T) {
cases := []struct {
name string
success bool
message string
}{
{"success_with_message", true, "build succeeded: artifacts uploaded"},
{"failure_with_message", false, "build failed: compilation error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
registry := runnerregistry.New()
s, store := testServerWithStore(registry)
jobID := "job-log-" + tc.name
runnerID := "runner-log-" + tc.name
execID := setupRunningExecution(t, store, registry, jobID, runnerID)
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
Success: tc.success,
Message: tc.message,
})
if !resp.GetAccepted() {
t.Fatalf("report rejected: %s", resp.GetErrorMessage())
}
logs, err := store.GetLogs(execID)
if err != nil {
t.Fatalf("GetLogs: %v", err)
}
found := false
for _, entry := range logs {
if entry.Line == tc.message {
found = true
break
}
}
if !found {
t.Errorf("expected execution log to contain %q; got entries: %v", tc.message, logs)
}
})
}
}
// TestServerHeartbeatUsesRegisteredRunnerID verifies that connectedRunnerIDs
// reflects only currently active clients and that clientFor returns nil after
// the current client is removed.
func TestServerHeartbeatUsesRegisteredRunnerID(t *testing.T) {
s := testServer(runnerregistry.New())
client1, _ := pipeClient(t)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners before any setClient; got %v", ids)
}
s.setClient("runner-D", client1)
ids := s.connectedRunnerIDs()
if len(ids) != 1 || ids[0] != "runner-D" {
t.Errorf("expected [runner-D] after setClient; got %v", ids)
}
// Current client removed → runner no longer in connected list.
s.removeClient("runner-D", client1)
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
t.Errorf("expected no connected runners after removeClient; got %v", ids)
}
if got := s.clientFor("runner-D"); got != nil {
t.Errorf("expected nil from clientFor after removeClient; got %v", got)
}
}