Test the step runner and the toolchain guard (issues 1471, 1473), part 5 of 7
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
type toolchainKeys map[string]delivery.Toolchain
|
||||
|
||||
func (k toolchainKeys) put(key string, value any) (uint64, error) {
|
||||
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
k[key] = tc
|
||||
return uint64(len(k)), nil
|
||||
}
|
||||
|
||||
func guardFor(k toolchainKeys, installed string, probeErr error) Guard {
|
||||
return Guard{Language: "go", Want: "1.27.0", Recipe: "r7", Node: "ace", Put: k.put,
|
||||
Probe: func(context.Context) (string, error) { return installed, probeErr }}
|
||||
}
|
||||
|
||||
// 1473/AC1: the probe answers the recipe's version: nil, and <node>.go holds that version and the recipe's.
|
||||
func TestCheckPassesAndWritesTheToolchain(t *testing.T) {
|
||||
k := toolchainKeys{}
|
||||
if err := guardFor(k, "1.27.0", nil).Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc, ok := k["ace.go"]
|
||||
if !ok || tc.Version != "1.27.0" || tc.Recipe != "r7" || tc.Language != "go" || tc.At.IsZero() {
|
||||
t.Errorf("keys = %+v", k)
|
||||
}
|
||||
}
|
||||
|
||||
// 1473/AC2: another version: a Refusal naming both, and the key holds the installed version.
|
||||
func TestCheckRefusesAnotherVersionAndWritesTheInstalledOne(t *testing.T) {
|
||||
k := toolchainKeys{}
|
||||
err := guardFor(k, "1.27.1", nil).Check(context.Background())
|
||||
var ref *Refusal
|
||||
if !errors.As(err, &ref) {
|
||||
t.Fatalf("Check = %v, want a Refusal", err)
|
||||
}
|
||||
if ref.Text != "go 1.27.1 installed, the recipe builds with go 1.27.0" || ref.Want != "1.27.0" || ref.Have != "1.27.1" {
|
||||
t.Errorf("refusal = %+v", ref)
|
||||
}
|
||||
if k["ace.go"].Version != "1.27.1" {
|
||||
t.Errorf("the key holds %q, want the installed 1.27.1", k["ace.go"].Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 1473/AC3: a probe that fails: a Refusal naming the failure, and nothing put.
|
||||
func TestCheckRefusesAFailedProbeAndPutsNothing(t *testing.T) {
|
||||
k := toolchainKeys{}
|
||||
err := guardFor(k, "", errors.New("go: not found")).Check(context.Background())
|
||||
var ref *Refusal
|
||||
if !errors.As(err, &ref) || !strings.Contains(ref.Text, "go: not found") {
|
||||
t.Fatalf("Check = %v, want a Refusal naming the probe's failure", err)
|
||||
}
|
||||
if len(k) != 0 {
|
||||
t.Errorf("put %v", k)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed write is an error, not a refusal: the step fails instead of refusing.
|
||||
func TestCheckAnswersAFailedWriteAsAnError(t *testing.T) {
|
||||
g := guardFor(toolchainKeys{}, "1.27.0", nil)
|
||||
g.Put = func(string, any) (uint64, error) { return 0, errors.New("no grant") }
|
||||
err := g.Check(context.Background())
|
||||
var ref *Refusal
|
||||
if err == nil || errors.As(err, &ref) {
|
||||
t.Errorf("Check = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// keys is a steps state in memory: every value put, in order, per key, and a signal for each end.
|
||||
type keys struct {
|
||||
mu sync.Mutex
|
||||
puts map[string][]delivery.Step
|
||||
ended chan string
|
||||
}
|
||||
|
||||
func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} }
|
||||
|
||||
func (k *keys) put(key string, value any) (uint64, error) {
|
||||
step, err := delivery.DecodeStep(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.puts[key] = append(k.puts[key], step)
|
||||
n := uint64(len(k.puts[key]))
|
||||
k.mu.Unlock()
|
||||
if step.State != delivery.StepRunning {
|
||||
k.ended <- key
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (k *keys) last(key string) delivery.Step {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
s := k.puts[key]
|
||||
return s[len(s)-1]
|
||||
}
|
||||
|
||||
func (k *keys) waitEnd(t *testing.T) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-k.ended:
|
||||
return key
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// 1471/AC1: a run that blocks: Start answers an id within 100 ms and the key holds running.
|
||||
func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
|
||||
k := newKeys()
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
began := time.Now()
|
||||
id, err := Start(context.Background(), k.put, "ace", "fetch", func(ctx context.Context) (any, error) {
|
||||
<-release
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := time.Since(began); d > 100*time.Millisecond {
|
||||
t.Errorf("Start took %v", d)
|
||||
}
|
||||
s := k.last("ace." + id)
|
||||
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
|
||||
t.Errorf("step = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1471/AC2 and 1473/AC4: a result, an error and a Refusal end the step passed, failed and refused.
|
||||
func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
run func(context.Context) (any, error)
|
||||
state string
|
||||
reason string
|
||||
result string
|
||||
}{
|
||||
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
|
||||
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
|
||||
{"refusal", func(context.Context) (any, error) {
|
||||
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
|
||||
}, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
k := newKeys()
|
||||
id, err := Start(context.Background(), k.put, "ace", "test", c.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := k.waitEnd(t); got != "ace."+id {
|
||||
t.Fatalf("ended key %q", got)
|
||||
}
|
||||
s := k.last("ace." + id)
|
||||
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
|
||||
t.Errorf("step = %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1471/AC3: a panic in run ends the step failed, naming it, and the test process goes on.
|
||||
func TestAPanicEndsTheStepFailed(t *testing.T) {
|
||||
k := newKeys()
|
||||
id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") })
|
||||
k.waitEnd(t)
|
||||
s := k.last("ace." + id)
|
||||
if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" {
|
||||
t.Errorf("step = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
|
||||
// while no step runs, so no goroutine reads it meanwhile.
|
||||
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
|
||||
was := now
|
||||
defer func() { now = was }()
|
||||
at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
|
||||
now = func() time.Time { return at }
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
id := NewID()
|
||||
if seen[id] {
|
||||
t.Fatalf("id %q handed out twice", id)
|
||||
}
|
||||
seen[id] = true
|
||||
if _, err := delivery.StepKey("ace", id); err != nil {
|
||||
t.Errorf("id %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A first write that fails starts nothing.
|
||||
func TestStartAnswersTheErrorWhenTheRunningWriteFails(t *testing.T) {
|
||||
ran := make(chan struct{}, 1)
|
||||
_, err := Start(context.Background(), func(string, any) (uint64, error) { return 0, errors.New("no grant") }, "ace", "fetch",
|
||||
func(context.Context) (any, error) { ran <- struct{}{}; return nil, nil })
|
||||
if err == nil {
|
||||
t.Fatal("Start answered no error")
|
||||
}
|
||||
select {
|
||||
case <-ran:
|
||||
t.Error("run was called")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user