Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

252 lines
9.1 KiB
Go

package daemon
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestOverlayManager_RegisterAndFiles is the happy-path round trip:
// register a session, push two overlays, list them via SnapshotFor.
// The snapshot must be path-sorted (the apply pass relies on stable
// ordering for reproducible drift errors) and must not alias the
// manager's internal map.
func TestOverlayManager_RegisterAndFiles(t *testing.T) {
m := NewOverlayManager(time.Minute)
id := m.Register("ws")
require.NotEmpty(t, id)
require.NoError(t, m.Push(id, OverlayFile{Path: "b.go", Content: "package b"}, nil))
require.NoError(t, m.Push(id, OverlayFile{Path: "a.go", Content: "package a"}, nil))
ws, files, err := m.SnapshotFor(id)
require.NoError(t, err)
require.Equal(t, "ws", ws)
require.Len(t, files, 2)
require.Equal(t, "a.go", files[0].Path, "snapshot must be path-sorted")
require.Equal(t, "b.go", files[1].Path)
// Snapshot must not alias the internal map.
files[0].Content = "mutated"
_, again, _ := m.SnapshotFor(id)
require.Equal(t, "package a", again[0].Content, "SnapshotFor must return a deep copy")
}
// TestOverlayManager_RegisterWithID_Idempotent verifies the MCP-side
// register flow: calling RegisterWithID twice for the same (sessionID,
// workspaceID) tuple is a no-op, but a workspace mismatch is rejected
// with ErrSessionExists. Without this contract the MCP overlay_register
// tool would have to teach every editor extension to track register
// state across reconnects.
func TestOverlayManager_RegisterWithID_Idempotent(t *testing.T) {
m := NewOverlayManager(time.Minute)
require.NoError(t, m.RegisterWithID("sess-1", "ws-a"))
require.NoError(t, m.RegisterWithID("sess-1", "ws-a"), "idempotent re-register must succeed")
err := m.RegisterWithID("sess-1", "ws-b")
require.ErrorIs(t, err, ErrSessionExists, "workspace mismatch must surface ErrSessionExists")
// Empty session ID is a programming error.
require.Error(t, m.RegisterWithID("", "ws-a"))
}
// TestOverlayManager_HasAndFileCount covers the dispatcher's fast-path
// gating: tools/call middleware bails before any apply work when
// Has==false or FileCount==0.
func TestOverlayManager_HasAndFileCount(t *testing.T) {
m := NewOverlayManager(time.Minute)
require.False(t, m.Has("unknown"))
require.Zero(t, m.FileCount("unknown"))
id := m.Register("ws")
require.True(t, m.Has(id))
require.Zero(t, m.FileCount(id), "freshly registered session has no files")
require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil))
require.Equal(t, 1, m.FileCount(id))
m.Drop(id)
require.False(t, m.Has(id))
require.Zero(t, m.FileCount(id))
}
// TestOverlayManager_DriftCheck verifies that Push surfaces a drift
// error when the supplied BaseSHA disagrees with the on-disk SHA
// reported by the callback. Without drift detection two clients
// pushing stale overlays against the same path would silently corrupt
// each other's query results.
func TestOverlayManager_DriftCheck(t *testing.T) {
m := NewOverlayManager(time.Minute)
id := m.Register("ws")
// BaseSHA matches: push succeeds.
require.NoError(t, m.Push(id,
OverlayFile{Path: "x.go", Content: "x", BaseSHA: "abc"},
func(path, sha string) bool { return sha == "abc" },
))
// BaseSHA mismatches: ErrOverlayDrift.
err := m.Push(id,
OverlayFile{Path: "x.go", Content: "x", BaseSHA: "stale"},
func(path, sha string) bool { return sha == "abc" },
)
require.True(t, errors.Is(err, ErrOverlayDrift))
}
// TestOverlayManager_SweepIdleHonoursTTL ensures that sessions older
// than IdleTTL are reaped. A crashed editor extension leaving overlays
// in the daemon would otherwise pin memory until restart.
func TestOverlayManager_SweepIdleHonoursTTL(t *testing.T) {
m := NewOverlayManager(20 * time.Millisecond)
id := m.Register("ws")
require.True(t, m.Has(id))
time.Sleep(40 * time.Millisecond)
dropped := m.SweepIdle()
require.Equal(t, 1, dropped, "session past idleTTL must be reaped")
require.False(t, m.Has(id))
}
func TestOverlayManager_StartJanitorSweepsAndStops(t *testing.T) {
m := NewOverlayManager(20 * time.Millisecond)
id := m.Register("ws")
require.True(t, m.Has(id))
swept := make(chan int, 1)
stop := m.StartJanitor(10*time.Millisecond, func(dropped int) {
select {
case swept <- dropped:
default:
}
})
defer stop()
select {
case dropped := <-swept:
require.Equal(t, 1, dropped, "janitor must reap the idle session")
case <-time.After(2 * time.Second):
t.Fatal("janitor never swept the idle session")
}
require.False(t, m.Has(id))
// stop is idempotent and terminates the goroutine.
stop()
stop()
}
func TestOverlayManager_StartJanitorDisabledTTL(t *testing.T) {
m := NewOverlayManager(0)
stop := m.StartJanitor(time.Millisecond, func(int) {
t.Error("janitor must not run when expiry is disabled")
})
stop()
stop()
}
// TestOverlayManager_SnapshotForBumpsLastUsed proves the load-bearing
// "reads count as activity" guarantee: a session that only queries
// (no further Push) keeps its lease alive as long as the view-build
// path keeps calling SnapshotFor. Without this a long sequence of
// tool calls without intervening pushes would let the TTL trip the
// session in active use.
func TestOverlayManager_SnapshotForBumpsLastUsed(t *testing.T) {
m := NewOverlayManager(60 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil))
// Two TTL/3 sleeps with a SnapshotFor in between — total span >
// TTL — but the read bumps LastUsed so the session survives.
time.Sleep(30 * time.Millisecond)
_, _, err := m.SnapshotFor(id)
require.NoError(t, err, "SnapshotFor must bump LastUsed")
time.Sleep(30 * time.Millisecond)
require.True(t, m.Has(id), "session must survive when reads bump LastUsed")
// Now truly idle: skip the read, sleep past TTL, sweep — should
// be reaped.
time.Sleep(80 * time.Millisecond)
dropped := m.SweepIdle()
require.Equal(t, 1, dropped)
require.False(t, m.Has(id))
}
// TestOverlayManager_TouchExtendsLease verifies the keepalive
// primitive: Touch refreshes LastUsed without altering files.
func TestOverlayManager_TouchExtendsLease(t *testing.T) {
m := NewOverlayManager(40 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "y.go", Content: "y"}, nil))
time.Sleep(25 * time.Millisecond)
require.NoError(t, m.Touch(id))
time.Sleep(25 * time.Millisecond)
require.True(t, m.Has(id), "Touch must keep the session alive past the original TTL window")
// Touch on unknown session reports a structured error.
require.ErrorIs(t, m.Touch("nope"), ErrSessionNotFound)
}
// TestOverlayManager_StatusForDoesNotBumpLastUsed makes sure the
// liveness-query path is read-only: polling overlay_list every
// second must NOT extend the lease (otherwise a misconfigured
// editor could keep a dropped session alive forever just by
// polling).
func TestOverlayManager_StatusForDoesNotBumpLastUsed(t *testing.T) {
m := NewOverlayManager(40 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "z.go", Content: "z"}, nil))
// Poll status every 10ms; total elapsed > TTL. Without
// LastUsed-bump on StatusFor, the sweeper still reaps.
for i := 0; i < 6; i++ {
_, err := m.StatusFor(id)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
}
dropped := m.SweepIdle()
require.Equal(t, 1, dropped, "StatusFor must NOT bump LastUsed; sweep should reap the idle session")
require.False(t, m.Has(id))
}
// TestOverlayManager_StatusForReportsExpiryMetadata: overlay_list
// surfaces this metadata so editor extensions can schedule
// keepalive proactively.
func TestOverlayManager_StatusForReportsExpiryMetadata(t *testing.T) {
m := NewOverlayManager(2 * time.Minute)
id := m.Register("ws-alpha")
st, err := m.StatusFor(id)
require.NoError(t, err)
require.Equal(t, "ws-alpha", st.WorkspaceID)
require.False(t, st.Created.IsZero())
require.False(t, st.LastUsed.IsZero())
require.InDelta(t, 0, st.IdleSeconds, 1.0, "newly-registered session has near-zero idle time")
require.InDelta(t, 120, st.IdleTTLSeconds, 0.5)
require.False(t, st.ExpiresAt.IsZero())
require.True(t, st.ExpiresAt.After(time.Now()), "expires_at must be in the future")
}
// TestOverlayIdleTTLFromEnv covers the three branches of the
// configuration resolution: explicit override > env var > default.
// t.Setenv auto-restores the original value on test completion, so
// the test never leaks env state into sibling tests in the package.
func TestOverlayIdleTTLFromEnv(t *testing.T) {
// Explicit non-zero override wins over env.
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "1h")
require.Equal(t, 7*time.Minute, OverlayIdleTTLFromEnv(7*time.Minute))
// Env var (no override).
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "45m")
require.Equal(t, 45*time.Minute, OverlayIdleTTLFromEnv(0))
// Garbage env: fall through to default (don't fail startup).
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "garbage")
require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0))
// Empty env (t.Setenv to "" is the documented way to model "unset"
// in a test-scoped way): default.
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "")
require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0))
}