package index import ( "context" "reflect" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) // The fixtures here are mesh-catalog a67f0196's manifests (testdata, captured from that commit) with the module renamed (and, // where a test needs one, a build.on entry added as the controller's own test has it, standing_on_test.go:22): the manifest // parser, not a hand-written manifest, reads them. func renamed(t *testing.T, from, to string) string { t.Helper() raw := catalogueManifest(t, from) out := strings.Replace(raw, `"module": "`+from+`"`, `"module": "`+to+`"`, 1) if out == raw { t.Fatalf("%s's manifest has no module line", from) } return out } func standingOn(t *testing.T, raw, base string) string { t.Helper() out := strings.Replace(raw, `"build": {`, `"build": { "on": [{"arg": "RUNTIME_BASE", "module": "`+base+`", "artifact": "runtime"}],`, 1) if out == raw { t.Fatal("the manifest has no build") } return out } const buildAgentRepo = "novox/mesh-controller" // build-agent's trunk builds as `mesh-controller.builds` showed them on 2026-10-21 (ids, times and source fingerprints // as listed; the repository is the clone URL the record names, the ref "main"; the inputs are the shape of aInputs, which // the indexer does not read for this): the older one at 13:03 and the newer at 15:03 (issue 1727). const ( agentSrcX = "src1:80e5fb129a98754a85fa3a43b1dded12dddedd25823d12dbe8783287faa0c59c" agentSrcY = "src1:44ed217d74a7c7e38400bb0bf32358ed96502cb385d24f1435fe971ad44c7b88" agentCloneURL = "http://novox.internal:20000/novox/mesh-controller.git" ) func agentBuild(id, src, at string) [2]any { commit := "cada7e3c" if id == "build-1792587792086548572" { commit = "f10fbda1" } raw := `{"id":"` + id + `","repository":"` + agentCloneURL + `","ref":"main","module":"build-agent","commit":"` + commit + `",` + `"path":"modules/build-agent","at":"` + at + `","source-fingerprint":"` + src + `","source-inputs":` + aInputs + `}` return [2]any{delivery.BuildKey("build-agent", id), []byte(raw)} } type mod struct { name, manifest string } // merged applies one merge of the catalogue carrying these modules (every one a Go bundle's go.sum, a TypeScript bundle's // package-lock.json) and returns the indexer. func newFormIndexer(t *testing.T, mods ...mod) (*Indexer, *memStore) { t.Helper() ix, store, f := newFormIndexerUnmerged(t, mods...) if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } // build-agent holds no fingerprint of today's form, as live (no merge of the controller is applied; issue 1727). return ix, store } // newFormIndexerUnmerged is the indexer and the catalogue merge of these modules, not yet applied. func newFormIndexerUnmerged(t *testing.T, mods ...mod) (*Indexer, *memStore, delivery.Forge) { t.Helper() var recs [][2]any trees, manifests := map[string]string{}, map[string]string{} locks := map[string]map[string]string{} for i, m := range mods { dir := "modules/" + m.name recs = append(recs, rec(t, "build-"+m.name, m.name, catalogueRepo, dir, "main", "", "2026-10-19T09:00:00Z", aInputs)) trees[dir], manifests[dir] = "tree-"+m.name, m.manifest locks[dir] = map[string]string{"go.sum": avahiGoSum, "package-lock.json": strings.Repeat("c", 63) + string(rune('a'+i))} } recs = append(recs, agentBuild("build-1792580585452696147", agentSrcX, "2026-10-21T13:03:00Z")) ix, store, _ := manifestsIndexer(t, recs...) return ix, store, withManifests(mergedMsg(catalogueRepo, trees), manifests, locks) } // toolchainValue is a toolchains value as the build module's writer encodes it (mesh-sdk go/delivery EncodeToolchain). func toolchainValue(t *testing.T, language, version, recipe string) []byte { t.Helper() b, err := delivery.EncodeToolchain(delivery.Toolchain{Language: language, Version: version, Recipe: recipe, At: time.Now()}) if err != nil { t.Fatal(err) } return b } func reportToolchain(t *testing.T, ix *Indexer, module, node, language, version string) { t.Helper() key, err := delivery.ToolchainKey(node, language) if err != nil { t.Fatal(err) } if err := ix.ApplyToolchain(context.Background(), module, key, toolchainValue(t, language, version, "1"), false); err != nil { t.Fatal(err) } } func TestAgreedToolchainChangesAGoModulesReasonWithNoMerge(t *testing.T) { // 1444/AC5 ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}) if m, _ := moduleOf(t, mods, "avahi"); m.None2 != delivery.ToolchainNoReport { t.Fatalf("before: none2 = %q", m.None2) } reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.27.1") m, _ := moduleOf(t, mods, "avahi") if m.None2 != "" || m.FP2 == "" { t.Errorf("none2 = %q fp2 = %q, want a fingerprint: no builder input is waited for (ADR 0530 §1)", m.None2, m.FP2) } } func TestNodesReportingDifferentVersionsLeaveTheReasonNamingBoth(t *testing.T) { // 1444/AC6 ix, mods := newFormIndexer(t, mod{"avahi", catalogueManifest(t, "avahi")}) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.26.0") m, _ := moduleOf(t, mods, "avahi") if !strings.Contains(m.None2, "builders disagree") || !strings.Contains(m.None2, "go1.27.1") || !strings.Contains(m.None2, "go1.26.0") { t.Errorf("none2 = %q, want 'builders disagree' naming both versions", m.None2) } } // builderModules are the builder, a base, a module on the base (named so that it is stored before its base) and a module // whose base is a TypeScript module with no toolchain reported. func builderModules(t *testing.T) []mod { return []mod{ {"mesh-delivery-builder", renamed(t, "avahi", "mesh-delivery-builder")}, {"mesh-tools", renamed(t, "avahi", "mesh-tools")}, {"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")}, {"other", standingOn(t, renamed(t, "avahi", "other"), "ts-base")}, {"ts-base", renamed(t, "audit-logger", "ts-base")}, } } func goInputs(t *testing.T, ix *Indexer, mods *memStore, module, builder string, bases ...string) delivery.SourceInputs2 { t.Helper() return delivery.SourceInputs2{Module: module, Tree: "tree-" + module, Builder: builder, Bases: bases, Locks: map[string]string{"tools": avahiGoSum}, Toolchains: map[string]delivery.Toolchain2{"tools": {Language: "go", Version: "go1.27.1", Recipe: "1"}}} } func fp2(t *testing.T, in delivery.SourceInputs2) string { t.Helper() fp, none := delivery.SourceFingerprint2(in) if none != "" { t.Fatal(none) } return fp } // 1887/I1, ADR 0530 §1: bases are computed before the modules taking them, and no module's fingerprint takes the builder, // the builder's own included: a trunk build of build-agent (or of the builder) moves nothing. func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // 1444/AC7, AC8 ix, mods := newFormIndexer(t, builderModules(t)...) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") builderFP := fp2(t, goInputs(t, ix, mods, "mesh-delivery-builder", "")) toolsFP := fp2(t, goInputs(t, ix, mods, "mesh-tools", "")) want := map[string]string{"mesh-delivery-builder": builderFP, "mesh-tools": toolsFP, "avahi": fp2(t, goInputs(t, ix, mods, "avahi", "", toolsFP))} for name, fp := range want { if m, _ := moduleOf(t, mods, name); m.FP2 != fp || m.None2 != "" || m.Inputs2 == nil || m.Inputs2.Builder != "" { t.Errorf("%s: fp2 = %q none2 = %q inputs %+v, want %s with no builder input", name, m.FP2, m.None2, m.Inputs2, fp) } } if m, _ := moduleOf(t, mods, "other"); m.FP2 != "" || !strings.Contains(m.None2, "ts-base") || !strings.Contains(m.None2, "base") { t.Errorf("other: fp2 = %q none2 = %q, want none2 naming the base ts-base", m.FP2, m.None2) } a := agentBuild("build-1792587792086548572", agentSrcY, "2026-10-21T15:03:00Z") ix.Builds.Put(a[0].(string), a[1].([]byte)) if err := ix.Recompute(context.Background()); err != nil { t.Fatal(err) } for name, fp := range want { if m, _ := moduleOf(t, mods, name); m.FP2 != fp { t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.FP2, fp) } } } func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2) ix, mods := newFormIndexer(t, builderModules(t)...) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") before, _ := moduleOf(t, mods, "mesh-tools") if before.FP2 == "" { t.Fatalf("mesh-tools has no fp2 to begin with: %+v", before) } // a restarted indexer has read no toolchains bucket yet ix.mu.Lock() ix.toolchains = nil ix.mu.Unlock() if err := ix.RecomputeAtStart(context.Background()); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "mesh-tools"); m.FP2 != before.FP2 || m.None2 != "" { t.Errorf("mesh-tools: fp2 = %q none2 = %q, want fp2 %q kept", m.FP2, m.None2, before.FP2) } } func TestAToolchainEntryChangedToTheSameValuesWritesNothing(t *testing.T) { // 1444/AC9 ix, mods := newFormIndexer(t, builderModules(t)...) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") before := mods.writes time.Sleep(2 * time.Millisecond) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // the same version and recipe, a later time if mods.writes != before { t.Errorf("modules written %d times after the same values, want none", mods.writes-before) } } func TestModulesThatStandOnEachOtherAreNamedAsACycle(t *testing.T) { // 1444 (the indexer has no graph: a cycle is a reason) ix, mods := newFormIndexer(t, mod{"aa", standingOn(t, renamed(t, "avahi", "aa"), "bb")}, mod{"bb", standingOn(t, renamed(t, "avahi", "bb"), "aa")}) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") for _, name := range []string{"aa", "bb"} { if m, _ := moduleOf(t, mods, name); m.FP2 != "" || !strings.Contains(m.None2, "cycle") { t.Errorf("%s: fp2 = %q none2 = %q, want a reason naming the cycle", name, m.FP2, m.None2) } } } func TestAMergeWritesEachModuleOnceWithItsFinalValueAndAReplayWritesNothing(t *testing.T) { // 1444 (review finding 8) ctx := context.Background() ix, mods, f := newFormIndexerUnmerged(t, mod{"avahi", catalogueManifest(t, "avahi")}) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") // agreed before the merge is heard if err := ix.Apply(ctx, f); err != nil { t.Fatal(err) } m, _ := moduleOf(t, mods, "avahi") if mods.writes != 1 || m.FP2 == "" { t.Errorf("writes = %d fp2 = %q after one merge, want one write with its fingerprint", mods.writes, m.FP2) } if err := ix.Apply(ctx, f); err != nil { t.Fatal(err) } if mods.writes != 1 { t.Errorf("writes = %d after the same merge again, want still 1", mods.writes) } } func TestAMergeKeepsAFingerprintTheModuleStillHas(t *testing.T) { // 1444 (review finding 8: a merge never blanks FP2) ctx := context.Background() ix, mods := newFormIndexer(t, builderModules(t)...) reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") before, _ := moduleOf(t, mods, "mesh-tools") if before.FP2 == "" { t.Fatalf("mesh-tools has no fp2: %+v", before) } w := mods.writes // the same catalogue merged again at a later commit: nothing moved _, _, f := newFormIndexerUnmerged(t, builderModules(t)...) if err := ix.Apply(ctx, f); err != nil { t.Fatal(err) } if after, _ := moduleOf(t, mods, "mesh-tools"); after.FP2 != before.FP2 || mods.writes != w { t.Errorf("fp2 = %q (was %q), writes %d more", after.FP2, before.FP2, mods.writes-w) } } func TestAModuleKeepsTheToolchainsItsFingerprintWasComputedWith(t *testing.T) { // 1615/AC3 ix, mods := newFormIndexer(t, builderModules(t)...) if m, _ := moduleOf(t, mods, "avahi"); m.Toolchains != nil { t.Fatalf("before a toolchain is reported: toolchains = %+v, want none", m.Toolchains) } reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") m, _ := moduleOf(t, mods, "avahi") want := map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}} if m.FP2 == "" || !reflect.DeepEqual(m.Toolchains, want) { t.Fatalf("avahi: fp2 %q toolchains %+v, want %+v", m.FP2, m.Toolchains, want) } if o, _ := moduleOf(t, mods, "other"); o.FP2 != "" || o.Toolchains != nil { t.Errorf("a module with no fingerprint carries toolchains %+v", o.Toolchains) } // An upgrade moves the fingerprint and the toolchains together, with no merge. reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.2") n, _ := moduleOf(t, mods, "avahi") if n.FP2 == m.FP2 || n.Toolchains["go"].Version != "go1.27.2" { t.Errorf("after the upgrade: fp2 %q (was %q) toolchains %+v, want the new version with the new fingerprint", n.FP2, m.FP2, n.Toolchains) } // Nodes that disagree leave no fingerprint and no toolchains. reportToolchain(t, ix, "mesh-build-go", "ace", "go", "go1.26.0") if d, _ := moduleOf(t, mods, "avahi"); d.FP2 != "" || d.Toolchains != nil { t.Errorf("disagreeing nodes: fp2 %q toolchains %+v, want neither", d.FP2, d.Toolchains) } }