Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/module_test.go
T

232 lines
7.5 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
// keptStates stands in for the toolchains and the steps states.
type keptStates struct {
mu sync.Mutex
toolchains map[string]delivery.Toolchain
steps map[string][]delivery.Step
ended chan string
}
func newStates() *keptStates {
return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)}
}
func (s *keptStates) putToolchain(key string, value any) (uint64, error) {
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.toolchains[key] = tc
return 1, nil
}
func (s *keptStates) putStep(key string, value any) (uint64, error) {
st, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
s.mu.Lock()
s.steps[key] = append(s.steps[key], st)
s.mu.Unlock()
if st.State != delivery.StepRunning {
s.ended <- key
}
return 1, nil
}
func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) {
s.mu.Lock()
defer s.mu.Unlock()
tc, ok := s.toolchains[key]
return tc, ok
}
func (s *keptStates) waitEnd(t *testing.T) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(3 * time.Second):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
func moduleWith(states *keptStates, installed string) *Module {
return &Module{Node: "ace", Recipe: Recipe{Steps: "1", Node: "v26.10.0", Lock: "lock"},
Probe: func(context.Context) (string, error) { return installed, nil },
Toolchains: states.putToolchain, Steps: states.putStep, Ctx: context.Background()}
}
// 1488/AC3: a probe answering v26.10.0: the bundle's start writes ace.typescript with that version and the recipe's,
// and the health tool answers healthy.
func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) {
states := newStates()
m := moduleWith(states, "v26.10.0")
ctx, stop := context.WithCancel(context.Background())
defer stop()
go m.Start(ctx)
var tc delivery.Toolchain
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
if got, ok := states.toolchain("ace.typescript"); ok {
tc = got
break
}
}
if tc.Version != "v26.10.0" || tc.Recipe != m.Recipe.Version() || tc.Language != "typescript" {
t.Fatalf("ace.typescript = %+v, want v26.10.0 and recipe %s", tc, m.Recipe.Version())
}
if a := m.Health(ctx); a.State != health.StateHealthy || !a.Healthy {
t.Errorf("health = %+v, want healthy", a)
}
}
// 1488/AC3: a probe answering v26.11.0: every step tool's step ends refused naming both versions, ace.typescript
// holds v26.11.0, and the health tool answers unhealthy naming both.
func TestAnotherNodeRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
states := newStates()
m := moduleWith(states, "v26.11.0")
ran := false
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
steps := append([]StepTool{
{Name: "long_probe", Long: true, Run: run},
{Name: "short_probe", Run: run},
}, (&Module{}).StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool {
continue
}
var reason string
out, err := tool.Run(map[string]any{})
if isLong(steps, tool.Name) {
if err != nil {
t.Fatalf("%s: %v", tool.Name, err)
}
if out.(map[string]any)["step"] == "" {
t.Fatalf("%s answered no step id: %v", tool.Name, out)
}
st := states.waitEnd(t)
if st.State != delivery.StepRefused {
t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State)
}
reason = st.Reason
} else {
if err == nil {
t.Fatalf("%s: answered %v, want a refusal", tool.Name, out)
}
reason = err.Error()
}
if !strings.Contains(reason, "v26.11.0") || !strings.Contains(reason, "v26.10.0") {
t.Errorf("%s: the refusal %q does not name both versions", tool.Name, reason)
}
}
if ran {
t.Errorf("a step ran on a node that refuses")
}
if tc, _ := states.toolchain("ace.typescript"); tc.Version != "v26.11.0" {
t.Errorf("ace.typescript holds %q, want the installed v26.11.0", tc.Version)
}
a := m.Health(context.Background())
if a.State != health.StateUnhealthy || !strings.Contains(a.Why, "v26.11.0") || !strings.Contains(a.Why, "v26.10.0") {
t.Errorf("health = %+v, want unhealthy naming both versions", a)
}
}
func isLong(steps []StepTool, name string) bool {
for _, s := range steps {
if s.Name == name {
return s.Long
}
}
return false
}
// 1488/AC2: the recipe version moves with one byte of the lock and with the Node version, and with the recipe's own
// version; equal recipes have the same one.
func TestRecipeVersionMovesWithEachOfItsValues(t *testing.T) {
lock := []byte(`{"lockfileVersion":3}`)
base := Recipe{Steps: "1", Node: "v26.10.0", Lock: LockHash(lock)}
if base.Version() != (Recipe{Steps: "1", Node: "v26.10.0", Lock: LockHash(lock)}).Version() {
t.Errorf("one recipe has two versions")
}
seen := map[string]string{base.Version(): "base"}
for name, r := range map[string]Recipe{
"one byte of the lock": {Steps: "1", Node: "v26.10.0", Lock: LockHash([]byte(`{"lockfileVersion":4}`))},
"node": {Steps: "1", Node: "v26.11.0", Lock: LockHash(lock)},
"steps": {Steps: "2", Node: "v26.10.0", Lock: LockHash(lock)},
} {
v := r.Version()
if was, dup := seen[v]; dup {
t.Errorf("the recipe differing in %s has the version of the %s recipe: %s", name, was, v)
}
seen[v] = name
}
}
// 1488/AC2: the recipe reads the lock file's bytes: a lock changed on disk is another recipe version, a lock missing
// is an error.
func TestReadRecipeHashesTheLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "package-lock.json")
if _, err := ReadRecipe(path); err == nil {
t.Errorf("a missing lock was accepted")
}
os.WriteFile(path, []byte("a"), 0o600)
a, err := ReadRecipe(path)
if err != nil || a.Node != "v26.10.0" {
t.Fatalf("= %+v, %v", a, err)
}
os.WriteFile(path, []byte("b"), 0o600)
b, _ := ReadRecipe(path)
if a.Version() == b.Version() {
t.Errorf("one byte of the lock did not move the recipe version")
}
}
// 1488/AC3: a lock changed under a running module is read at the next check, and a lock that cannot be read refuses.
func TestACheckReadsTheRecipeAgain(t *testing.T) {
states := newStates()
m := moduleWith(states, "v26.10.0")
r2 := Recipe{Steps: "1", Node: "v26.10.0", Lock: "other"}
m.Reload = func() (Recipe, error) { return r2, nil }
if err := m.Check(context.Background()); err != nil {
t.Fatal(err)
}
if tc, _ := states.toolchain("ace.typescript"); tc.Recipe != r2.Version() {
t.Errorf("ace.typescript holds recipe %s, want the reloaded %s", tc.Recipe, r2.Version())
}
m.Reload = func() (Recipe, error) { return Recipe{}, errors.New("no lock") }
if err := m.Check(context.Background()); err == nil || m.Health(context.Background()).Healthy {
t.Errorf("a lock that cannot be read neither refused nor made the module unhealthy: %v", err)
}
}
func TestParseNodeVersion(t *testing.T) {
if v, err := parseNodeVersion("v26.10.0\n"); err != nil || v != "v26.10.0" {
t.Errorf("= %q, %v", v, err)
}
if _, err := parseNodeVersion("not node"); err == nil {
t.Errorf("accepted a text that is not `node --version`'s")
}
}