a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
205 lines
7.7 KiB
Go
205 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func fakeRepoEntry(path string) config.RepoEntry {
|
|
return config.RepoEntry{Path: path}
|
|
}
|
|
|
|
// TestSnapshot_RestartStability is the B1 regression guard at the
|
|
// daemon-snapshot layer: N save/load cycles on top of the existing
|
|
// graph must not grow node or edge counts. Before Phase 2, re-running
|
|
// a snapshot load on top of a warm graph doubled edges every cycle
|
|
// because AddNode/AddEdge appended blindly.
|
|
func TestSnapshot_RestartStability(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
orig := graph.New()
|
|
orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
orig.AddNode(&graph.Node{ID: "a.go::Bar", Name: "Bar", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
orig.AddEdge(&graph.Edge{
|
|
From: "a.go::Foo", To: "a.go::Bar",
|
|
Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5,
|
|
})
|
|
want := orig.Stats()
|
|
|
|
// Save once; then repeatedly load into the same graph. Each
|
|
// load re-applies AddNode / AddEdge for every record. With
|
|
// idempotent writes, stats must not drift.
|
|
saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
for cycle := 0; cycle < 5; cycle++ {
|
|
result, err := loadSnapshot(orig, zap.NewNop())
|
|
require.NoError(t, err, "cycle %d", cycle)
|
|
require.True(t, result.Loaded, "cycle %d", cycle)
|
|
got := orig.Stats()
|
|
assert.Equal(t, want.TotalNodes, got.TotalNodes,
|
|
"cycle %d: nodes drifted", cycle)
|
|
assert.Equal(t, want.TotalEdges, got.TotalEdges,
|
|
"cycle %d: edges drifted (B1 regression)", cycle)
|
|
}
|
|
}
|
|
|
|
// TestSnapshot_RoundTripsRepos verifies the new per-repo FileMtimes
|
|
// section survives encode → decode. Without this, IncrementalReindex on
|
|
// warmup treats every file as stale (empty mtime map → IsStale=true for
|
|
// all), producing the same duplicate-writes problem we fixed.
|
|
func TestSnapshot_RoundTripsRepos(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "repo/a.go::X", Name: "X", Kind: graph.KindFunction, FilePath: "repo/a.go", RepoPrefix: "repo"})
|
|
|
|
repos := []snapshotRepo{{
|
|
RepoPrefix: "repo",
|
|
RootPath: "/abs/path/to/repo",
|
|
FileMtimes: map[string]int64{
|
|
"a.go": 1000,
|
|
"b/c.go": 2000,
|
|
"x/y/z.go": 3000,
|
|
},
|
|
}}
|
|
|
|
saveSnapshot(g, repos, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
require.NotNil(t, result.Repos, "repo section must round-trip")
|
|
|
|
r, ok := result.Repos["repo"]
|
|
require.True(t, ok, "repo prefix must be keyed correctly")
|
|
assert.Equal(t, "/abs/path/to/repo", r.RootPath)
|
|
assert.Equal(t, int64(1000), r.FileMtimes["a.go"])
|
|
assert.Equal(t, int64(2000), r.FileMtimes["b/c.go"])
|
|
assert.Equal(t, int64(3000), r.FileMtimes["x/y/z.go"])
|
|
}
|
|
|
|
// TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos verifies that a
|
|
// snapshot written without repo metadata (the v2 layout, pre-Phase 1)
|
|
// loads cleanly on the new daemon — the RepoCount field decodes as
|
|
// zero, the repo section is empty, and Repos is nil. The new daemon
|
|
// falls back to TrackRepoCtx, which does a full re-index.
|
|
func TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
|
|
// No repos passed — simulates a snapshot from before the
|
|
// RepoCount field existed. (The on-disk bytes are the same
|
|
// either way: saveSnapshot writes zero repo records when the
|
|
// slice is empty, matching what older daemons produced.)
|
|
saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Loaded)
|
|
assert.False(t, result.Partial)
|
|
assert.Empty(t, result.Repos, "empty repos must surface as nil/empty map, not an error")
|
|
assert.Equal(t, 1, restored.NodeCount())
|
|
}
|
|
|
|
// TestSnapshot_DanglingEdgesDropped verifies the structural-validation
|
|
// pass: edges whose endpoints aren't in the loaded node set are
|
|
// dropped, not stored. A corrupt or truncated snapshot that landed
|
|
// with dangling refs used to surface later as "edge pointing at nil
|
|
// node" panics in traversal code; the loader's contract is to drop
|
|
// them at load time.
|
|
func TestSnapshot_DanglingEdgesDropped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz"))
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "a.go::A", Name: "A", Kind: graph.KindFunction, FilePath: "a.go"})
|
|
// B is deliberately NOT added — this edge will be dangling on load.
|
|
// AddEdge still stores it (the graph doesn't validate at insert
|
|
// time), but loadSnapshot should reject it during the structural
|
|
// validation pass after nodes are in place.
|
|
g.AddEdge(&graph.Edge{
|
|
From: "a.go::A", To: "b.go::B",
|
|
Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1,
|
|
})
|
|
|
|
saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop())
|
|
|
|
restored := graph.New()
|
|
result, err := loadSnapshot(restored, zap.NewNop())
|
|
require.NoError(t, err)
|
|
require.True(t, result.Loaded)
|
|
assert.Equal(t, 1, restored.NodeCount(), "only the valid node should land")
|
|
assert.Equal(t, 0, restored.EdgeCount(),
|
|
"dangling edge must be dropped during load validation")
|
|
}
|
|
|
|
// TestCanMigrate covers the migration-chain guard that gates between
|
|
// "attempt to migrate" and "discard cache on schema mismatch" paths.
|
|
// The registry ships empty (Phase 4 is scaffolding) so every answer
|
|
// today is `false`, but the shape of the lookup matters — a future
|
|
// PR that lands a real migration needs this to return `true` only
|
|
// when the chain is complete.
|
|
func TestCanMigrate(t *testing.T) {
|
|
// from >= to → no migration needed.
|
|
assert.False(t, canMigrate(3, 3))
|
|
assert.False(t, canMigrate(5, 2))
|
|
|
|
// Empty registry — no migrations are available.
|
|
assert.False(t, canMigrate(1, 2))
|
|
|
|
// Install a fake v1→v2 migration. A complete chain reports true;
|
|
// a missing link (v2→v3 never registered) reports false.
|
|
orig := snapshotMigrations
|
|
t.Cleanup(func() { snapshotMigrations = orig })
|
|
snapshotMigrations = map[int]snapshotMigration{
|
|
1: func(in io.Reader, out io.Writer) error { return nil },
|
|
}
|
|
assert.True(t, canMigrate(1, 2))
|
|
assert.False(t, canMigrate(1, 3), "missing v2→v3 link breaks the chain")
|
|
}
|
|
|
|
// TestPriorMtimesForEntry_MatchesByAbsRootPath is the core of the
|
|
// warmup decision: given a snapshot's per-repo metadata and a config
|
|
// entry, priorMtimesForEntry must resolve the right FileMtimes for
|
|
// incremental reconciliation. A miss here causes warmup to fall back
|
|
// to full re-indexing — not a correctness bug, but the whole point of
|
|
// Phase 1 is to avoid the full pass on routine restart.
|
|
func TestPriorMtimesForEntry_MatchesByAbsRootPath(t *testing.T) {
|
|
absPath := filepath.Join(t.TempDir(), "repo")
|
|
|
|
repos := map[string]*snapshotRepo{
|
|
"repo": {
|
|
RepoPrefix: "repo",
|
|
RootPath: absPath,
|
|
FileMtimes: map[string]int64{"a.go": 42},
|
|
},
|
|
}
|
|
|
|
// Exact absolute-path match.
|
|
entry := fakeRepoEntry(absPath)
|
|
got := priorMtimesForEntry(repos, entry)
|
|
assert.Equal(t, int64(42), got["a.go"])
|
|
|
|
// No match → nil.
|
|
miss := fakeRepoEntry(filepath.Join(t.TempDir(), "nope"))
|
|
assert.Nil(t, priorMtimesForEntry(repos, miss))
|
|
|
|
// Empty repos map is safe — no panic, returns nil.
|
|
assert.Nil(t, priorMtimesForEntry(nil, entry))
|
|
}
|