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", 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) } } // 1572/AC2 (ADR 0477 ยง2): a node that cannot be run: every step tool's step ends refused naming the cause, and the // health tool answers unhealthy naming it. func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) { states := newStates() m := moduleWith(states, "") m.Probe = func(context.Context) (string, error) { return "", errors.New("exec: node: executable file not found in $PATH") } 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 || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool { 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, "executable file not found") { t.Errorf("%s: the refusal %q does not name the cause", tool.Name, reason) } } if ran { t.Errorf("a step ran on a node that refuses") } a := m.Health(context.Background()) if a.State != health.StateUnhealthy || !strings.Contains(a.Why, "executable file not found") { t.Errorf("health = %+v, want unhealthy naming the cause", a) } } func isLong(steps []StepTool, name string) bool { for _, s := range steps { if s.Name == name { return s.Long } } return false } // 1488/AC2, 1572/AC3: the recipe version moves with one byte of the lock 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", Lock: LockHash(lock)} if base.Version() != (Recipe{Steps: "1", 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", Lock: LockHash([]byte(`{"lockfileVersion":4}`))}, "steps": {Steps: "2", 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.Steps != RecipeSteps { 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", 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") } }