The operator, 2026-11-02: "A failed check shows only its first fault". Group feat/1126-removal's check failed four times, each verdict naming one fault, so three faults took three runs of 20-40 minutes. The checker no longer returns at a member's first failure: every member is asked, every level's failures and every member's refused step or conflict are joined in judging order, the manifests are judged and the composition is read also when the tests failed, and a failed composition is added after them. The status line says how many failed and names the first, cut so "; and N more" stays whole. checker_verdicts and checker_pull answer the whole list (failed). Needs mesh-sdk's buildstep change (every level runs after a failure) pinned at the group's head. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
234 lines
9.1 KiB
Go
234 lines
9.1 KiB
Go
package check
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// memTries is the tries state as the tools see it, over a memKV.
|
|
type memTries struct{ kv *memKV }
|
|
|
|
func (m memTries) Keys() ([]string, error) { return m.kv.Keys(context.Background()) }
|
|
func (m memTries) Get(key string) (json.RawMessage, error) {
|
|
v, _, _, err := m.kv.Get(context.Background(), key)
|
|
return v, err
|
|
}
|
|
func (m memTries) Create(key string, value []byte) error {
|
|
_, err := m.kv.Create(context.Background(), key, value)
|
|
return err
|
|
}
|
|
|
|
// writtenTries is a memKV that keeps every running try it was given to write, in order.
|
|
type writtenTries struct {
|
|
*memKV
|
|
running []Try
|
|
}
|
|
|
|
func (w *writtenTries) Update(ctx context.Context, key string, value []byte, rev uint64) (uint64, error) {
|
|
if t, err := DecodeTry(value); err == nil && !t.Over() {
|
|
w.running = append(w.running, t)
|
|
}
|
|
return w.memKV.Update(ctx, key, value, rev)
|
|
}
|
|
|
|
var tryTrunks = map[string]string{repoA: trunkA, repoSDK: trunkS, hqRepo: trunkA}
|
|
|
|
func readTry(t testing.TB, kv *memKV, id string) Try {
|
|
t.Helper()
|
|
raw, _, found, _ := kv.Get(context.Background(), id)
|
|
if !found {
|
|
t.Fatalf("no try %s", id)
|
|
}
|
|
tr, err := DecodeTry(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
func TestATryIsAskedQueuedOncePerHeadAndAtMostFourAtOnce(t *testing.T) { // ADR 0501 §5
|
|
kv := newMemKV()
|
|
store := memTries{kv}
|
|
at := time.Date(2026, 10, 22, 18, 0, 0, 0, time.UTC)
|
|
id, err := AskTry(store, tryTrunks, TryAsk{Repository: repoA, Head: head1, Trunk: "main"}, at)
|
|
if err != nil || !strings.HasPrefix(id, "try-") {
|
|
t.Fatalf("id %q, %v", id, err)
|
|
}
|
|
tr := readTry(t, kv, id)
|
|
if tr.State != TryQueued || tr.Trunk != trunkA || tr.Branch != "main" || tr.Head != head1 || tr.Repository != repoA {
|
|
t.Errorf("try = %+v, want queued on the trunk's head", tr)
|
|
}
|
|
again, err := AskTry(store, tryTrunks, TryAsk{Repository: repoA, Head: head1, Trunk: "main"}, at.Add(time.Second))
|
|
if err != nil || again != id {
|
|
t.Errorf("a second ask of the same head answered %q, %v, want %q", again, err, id)
|
|
}
|
|
// Issue 1890: tries on a build node (a step of theirs running, or none named yet) and tries waiting for one (queued, or
|
|
// their asks not taken) are bounded apart, MaxTries and MaxTriesWaiting; the refusal names both counts.
|
|
onNode := func(i int) Try {
|
|
return Try{ID: fmt.Sprintf("try-%d", i), Repository: repoA, Head: fmt.Sprintf("%040x", i), Trunk: trunkA, State: TryRunning,
|
|
Steps: []TryStep{{Ask: "a", State: delivery.StepRunning}}}
|
|
}
|
|
waitingFor := func(i int) Try {
|
|
return Try{ID: fmt.Sprintf("try-%d", i), Repository: repoA, Head: fmt.Sprintf("%040x", i), Trunk: trunkA, State: TryRunning,
|
|
Steps: []TryStep{{Ask: "a", State: TryAsked}}}
|
|
}
|
|
for _, c := range []struct {
|
|
what string
|
|
holding, wait int
|
|
refused string
|
|
}{
|
|
{"four queued behind the checks, none on a node: a fifth is taken", 0, 4, ""},
|
|
{"four on a node", MaxTries, 0, "now 4 run and 0 wait"},
|
|
{"three on a node, eight waiting", 3, MaxTriesWaiting, "a new try is refused while 4 tries run on a build node or 8 wait for one; now 3 run and 8 wait"},
|
|
} {
|
|
kv := newMemKV()
|
|
for i := range c.holding + c.wait {
|
|
tr := waitingFor(i + 1)
|
|
if i < c.holding {
|
|
tr = onNode(i + 1)
|
|
}
|
|
data, _ := encodeTry(tr)
|
|
_, _ = kv.Create(context.Background(), tr.ID, data)
|
|
}
|
|
_, err := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoSDK, Head: head1}, at.Add(time.Minute))
|
|
if (c.refused == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), c.refused)) {
|
|
t.Errorf("%s: answered %v, want refused %q", c.what, err, c.refused)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATryOfARepositoryWithNoCheckedLevelIsRefused(t *testing.T) { // the agreed error of the try
|
|
store := memTries{newMemKV()}
|
|
for _, repo := range []string{"novox/no-trunk", "novox/mesh-lab"} { // hq is checked since R4g (ADR 0527): see tryTrunks
|
|
_, err := AskTry(store, tryTrunks, TryAsk{Repository: repo, Head: head1, Trunk: "main"}, time.Now())
|
|
if err == nil || err.Error() != "no level of "+repo+" is checked by the checker" {
|
|
t.Errorf("%s: %v", repo, err)
|
|
}
|
|
}
|
|
for _, bad := range []TryAsk{{Repository: repoA, Head: "abc"}, {Repository: repoA, Head: head1, Level: "e2e"},
|
|
{Repository: repoA, Head: head1, Tests: "a b"}, {Repository: "x", Head: head1}, {Repository: repoA, Head: head1, Trunk: "-x"}} {
|
|
if _, err := AskTry(store, tryTrunks, bad, time.Now()); err == nil {
|
|
t.Errorf("%+v was taken", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATryIsJudgedAsAPullRequestsHeadAndOwesTheForgeNothing(t *testing.T) { // ADR 0501 §5
|
|
ctx := context.Background()
|
|
kv := newMemKV()
|
|
id, err := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1, Trunk: trunkA}, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := newBench(t)
|
|
written := &writtenTries{memKV: kv}
|
|
tr := &Tryer{Tries: written, Judge: b.ex}
|
|
raw, _, _, _ := kv.Get(ctx, id)
|
|
tr.Put(ctx, id, raw)
|
|
tr.Wait()
|
|
got := readTry(t, kv, id)
|
|
if got.State != TryPass || got.Took == "" || got.Head != head1 || got.Trunk != trunkA || got.Said == "" || got.Levels[delivery.LevelUnit].Run != 2 {
|
|
t.Fatalf("try = %+v", got)
|
|
}
|
|
// Issue 1890: while it runs, the try names its ask as soon as it is put, and then where its step stands; the end
|
|
// keeps each ask's node and state.
|
|
if len(written.running) < 3 || len(written.running[1].Asks) != 1 || written.running[1].Steps[0].State != TryAsked ||
|
|
written.running[2].Steps[0].Node != "shanks" {
|
|
t.Errorf("the running try's writes = %+v, want its ask named while it ran, then its node", written.running)
|
|
}
|
|
if len(got.Steps) != 1 || got.Steps[0].Node != "shanks" || got.Steps[0].State != delivery.StepPassed {
|
|
t.Errorf("the ended try's steps = %+v", got.Steps)
|
|
}
|
|
// The try names the asks it put, so the delivery view counts their steps as a try's (build_usage).
|
|
if len(got.Asks) != 1 {
|
|
t.Errorf("asks = %v, want the one check ask the try put", got.Asks)
|
|
} else if _, err := delivery.AskStepKey("shanks", got.Asks[0]); err != nil {
|
|
t.Errorf("the try names %q, which is no ask id: %v", got.Asks[0], err)
|
|
}
|
|
fetch := b.steps.tool("fetch")
|
|
if len(fetch) != 1 || fetch[0].args["commit"] != head1 || fetch[0].args["base"] != trunkA || fetch[0].args["ref"] != "" {
|
|
t.Errorf("fetch = %+v, want the head merged onto the trunk, with no pull request's ref", fetch)
|
|
}
|
|
if keys, _ := b.prev.Keys(ctx); len(keys) != 0 {
|
|
t.Errorf("a try wrote the previews %v", keys)
|
|
}
|
|
// An ended try is not run again.
|
|
raw, _, _, _ = kv.Get(ctx, id)
|
|
tr.Put(ctx, id, raw)
|
|
tr.Wait()
|
|
if n := len(b.steps.tool("fetch")); n != 1 {
|
|
t.Errorf("an ended try was fetched again: %d fetches", n)
|
|
}
|
|
}
|
|
|
|
func TestATryOfOneLevelAndSomeTestsRunsThemAloneAndKeepsNothing(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv := newMemKV()
|
|
id, err := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1, Level: delivery.LevelIntegration, Tests: "TestBus|TestStore"}, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := newBench(t)
|
|
tr := &Tryer{Tries: kv, Judge: b.ex}
|
|
raw, _, _, _ := kv.Get(ctx, id)
|
|
tr.Put(ctx, id, raw)
|
|
tr.Wait()
|
|
tests := b.steps.tool("test")
|
|
if len(tests) != 1 || tests[0].args["level"] != delivery.LevelIntegration || tests[0].args["run"] != "TestBus|TestStore" {
|
|
t.Fatalf("tests = %+v, want the integration level alone with the run pattern", tests)
|
|
}
|
|
if n := len(b.steps.tool("digest")); n != 0 {
|
|
t.Errorf("%d digests for a try of some tests", n)
|
|
}
|
|
if keys, _ := b.kept.Keys(ctx); len(keys) != 0 {
|
|
t.Errorf("kept %v from a try of some tests", keys)
|
|
}
|
|
if got := readTry(t, kv, id); got.State != TryPass || !strings.Contains(got.Said, "only the tests TestBus|TestStore") {
|
|
t.Errorf("try = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv := newMemKV()
|
|
b := newBench(t)
|
|
b.test = func(c stepCall) (any, error) { return captured[Tested](t, "go-test-failing"), nil }
|
|
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
|
tr := &Tryer{Tries: kv, Judge: b.ex}
|
|
raw, _, _, _ := kv.Get(ctx, id)
|
|
tr.Put(ctx, id, raw)
|
|
tr.Wait()
|
|
if got := readTry(t, kv, id); got.State != TryFail || len(got.Failed) != 2 || !strings.Contains(got.Failed[0], "unit: fixture/failing/bad.TestThreeIsFour") ||
|
|
!strings.Contains(got.Failed[1], "integration: fixture/failing/bad.TestThreeIsFour") { // both levels ran (the operator, 2026-11-02)
|
|
t.Errorf("try = %+v", got)
|
|
}
|
|
// No build node is no error since R4g: the try's ask waits in the check stream until a node pulls it (ADR 0525 §4).
|
|
}
|
|
|
|
func TestATryLeftRunningIsTakenOver(t *testing.T) {
|
|
ctx := context.Background()
|
|
kv := newMemKV()
|
|
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
|
// Left running by a checker that stopped.
|
|
raw, rev, _, _ := kv.Get(ctx, id)
|
|
cur, _ := DecodeTry(raw)
|
|
cur.State = TryRunning
|
|
data, _ := encodeTry(cur)
|
|
if _, err := kv.Update(ctx, id, data, rev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := newBench(t)
|
|
tr := &Tryer{Tries: kv, Judge: b.ex}
|
|
tr.Reconsider(ctx)
|
|
tr.Wait()
|
|
if got := readTry(t, kv, id); got.State != TryPass {
|
|
t.Errorf("the try left running = %+v, want taken over and passed", got)
|
|
}
|
|
}
|