mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* test(server): reduce slow unit test waits Co-authored-by: multica-agent <[email protected]> * test(server): cut slow Go unit test time further Follow-up to the first pass on MUL-7271. Test-only changes plus a few package-private seams whose production values are unchanged. - Re-executed race-built test binaries no longer pay the race runtime's 1s atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only). - pkg/agent: event-driven or shorter test-only bounds for handshake, drain and idle graces; redundant timeout variants merged. - daemon/execenv: git fixtures copied from a template repo built once; tests that re-ran the same worktree lifecycle folded into their supersets; ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY. - handler/service: cap and paging tests shrink their caps instead of seeding thousands of rows; lock waits probe pg_locks instead of sleeping; one shared pgxpool per service test binary; cheaper cron and RSA fixtures. - wecom: the phishing-link sweep reuses one parser and derives CRLF from LF with a sampled cross-check; fake sockets acknowledge instead of waiting out the ack timeout. - migrate: the migration corpus is parsed once; isolated DB tests run in parallel. Co-authored-by: multica-agent <[email protected]> * test(server): remove transport timeout unit tests Co-authored-by: multica-agent <[email protected]> * test(server): restore two non-timeout tests and make a bound load-bearing Follow-up to the review of #8318. All test-only; no production code changes. - daemon: restore the three read-liveness cases that the transport-timeout sweep took with it. They assert that a ping, a pong and an application frame each EXTEND the read deadline, so a healthy wakeup socket survives without traffic of our own — the deadline never fires on the happy path, which is why they are not transport-timeout tests. Verified they fail (all three) when the extension calls are removed from wakeup.go, and pass in 0.57s together. The pure "times out without peer traffic" case stays deleted. - pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction pair. codex.go treats a parent deadline and a parent cancellation as one ending; only the Canceled arm was left covered. The fake now writes the secret to stderr before reading the handshake, so the check no longer races our own initialize write, and the budget is 2s against a measured 430-465ms launch. Verified it fails when the !contextEnded guard is removed. - pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound widened to a literal 5s, which the shrunk 750ms drain grace can never reach — the test could no longer fail. It now raises the drain grace to 10s for its own duration and asserts under 3s: healthy runs take 0.19-0.44s, and a broken short-circuit takes 10.19s and fails. - wecom: gofmt the import block in relay_settle_budget_test.go. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: J <[email protected]>
445 lines
16 KiB
Go
445 lines
16 KiB
Go
package execenv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// openclawCacheFixture is one host: a real (fake) openclaw binary file, a real
|
|
// user config file, a cache directory, and a stubbed CLI that counts calls.
|
|
// The binary and config have to exist on disk because the cache fingerprint
|
|
// stats both — that is the whole invalidation mechanism.
|
|
type openclawCacheFixture struct {
|
|
bin string
|
|
configPath string
|
|
cacheDir string
|
|
stub *openclawCLIStub
|
|
}
|
|
|
|
func newOpenclawCacheFixture(t *testing.T) *openclawCacheFixture {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "openclaw")
|
|
writeTestExecutable(t, bin, []byte("#!/bin/sh\nexit 0\n"))
|
|
configPath := filepath.Join(dir, "openclaw.json")
|
|
if err := os.WriteFile(configPath, []byte(`{ "agents": { "list": [] } }`), 0o600); err != nil {
|
|
t.Fatalf("write user config: %v", err)
|
|
}
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: configPath + "\n"},
|
|
"config get agents.list --json": {stdout: `[{"id":"scout"}]`},
|
|
})
|
|
stub.bin = bin
|
|
return &openclawCacheFixture{
|
|
bin: bin,
|
|
configPath: configPath,
|
|
cacheDir: t.TempDir(),
|
|
stub: stub,
|
|
}
|
|
}
|
|
|
|
func (f *openclawCacheFixture) prep() OpenclawConfigPrep {
|
|
return OpenclawConfigPrep{OpenclawBin: f.bin, CacheDir: f.cacheDir}
|
|
}
|
|
|
|
// run executes one full preparation into a throwaway env root and returns how
|
|
// many CLI invocations it needed.
|
|
func (f *openclawCacheFixture) run(t *testing.T) int {
|
|
t.Helper()
|
|
before := len(f.stub.calls)
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
if _, err := prepareOpenclawConfig(envRoot, workDir, f.prep()); err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
return len(f.stub.calls) - before
|
|
}
|
|
|
|
func (f *openclawCacheFixture) cachePath() string {
|
|
return filepath.Join(f.cacheDir, openclawDiscoveryCacheFile)
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheServesSecondPreparation is the headline behaviour.
|
|
// Discovery is two serial CLI calls that cost ~12.7s on the host in #7112, and
|
|
// every task — including every chat message, which goes through Reuse — used
|
|
// to pay them again. Raising the deadline without this would trade a hard
|
|
// failure for a per-message stall.
|
|
func TestOpenclawDiscoveryCacheServesSecondPreparation(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Fatalf("cold preparation made %d CLI calls, want 2 (config file + agents.list)", got)
|
|
}
|
|
if got := f.run(t); got != 0 {
|
|
t.Errorf("warm preparation made %d CLI calls, want 0 (served from cache)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheHitProducesSameWrapper guards the actual contract:
|
|
// a cache hit has to produce the byte-identical wrapper a live run would, or
|
|
// the speedup silently changes what OpenClaw loads.
|
|
func TestOpenclawDiscoveryCacheHitProducesSameWrapper(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
|
|
read := func() []byte {
|
|
t.Helper()
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, f.prep())
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
raw, err := os.ReadFile(result.ConfigPath)
|
|
if err != nil {
|
|
t.Fatalf("read wrapper: %v", err)
|
|
}
|
|
// The workspace override is env-root specific; normalise it away so the
|
|
// comparison is about the discovered content, not the temp path.
|
|
return []byte(strings.ReplaceAll(string(raw), workDir, "<WORKDIR>"))
|
|
}
|
|
|
|
cold := read()
|
|
warm := read()
|
|
if string(cold) != string(warm) {
|
|
t.Errorf("cache hit produced a different wrapper\ncold: %s\nwarm: %s", cold, warm)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheInvalidatesOnConfigChange covers the refresh
|
|
// semantics Reuse already promised: a user who adds an agent or rotates a
|
|
// provider must not keep getting the stale list for the rest of the TTL.
|
|
func TestOpenclawDiscoveryCacheInvalidatesOnConfigChange(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
// Rewrite the active config with different content. Size changes, so this
|
|
// is caught even on filesystems with coarse mtime granularity.
|
|
if err := os.WriteFile(f.configPath, []byte(`{ "agents": { "list": [ { "id": "scout" } ] } }`), 0o600); err != nil {
|
|
t.Fatalf("rewrite user config: %v", err)
|
|
}
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation after a config edit made %d CLI calls, want 2 (cache must be invalid)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheInvalidatesOnBinaryChange covers an openclaw
|
|
// upgrade in place: same path, new build, potentially a different active
|
|
// config or agents schema.
|
|
func TestOpenclawDiscoveryCacheInvalidatesOnBinaryChange(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
writeTestExecutable(t, f.bin, []byte("#!/bin/sh\n# upgraded\nexit 0\n"))
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation after a binary upgrade made %d CLI calls, want 2 (cache must be invalid)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheInvalidatesOnEnvChange covers the pointers that
|
|
// move the active config without touching any file we stat: OpenClaw's own
|
|
// path environment.
|
|
func TestOpenclawDiscoveryCacheInvalidatesOnEnvChange(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
t.Setenv("OPENCLAW_STATE_DIR", filepath.Join(t.TempDir(), "state"))
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation after an OpenClaw env change made %d CLI calls, want 2 (cache must be invalid)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheExpiresWithTTL covers the drift the fingerprint
|
|
// cannot see: a nested `$include` target edited in place, or an agent added
|
|
// through OpenClaw's own registry. Neither touches the top-level config, so
|
|
// the TTL is the only backstop.
|
|
func TestOpenclawDiscoveryCacheExpiresWithTTL(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
raw, err := os.ReadFile(f.cachePath())
|
|
if err != nil {
|
|
t.Fatalf("read cache: %v", err)
|
|
}
|
|
var entry openclawDiscoveryCacheEntry
|
|
if err := json.Unmarshal(raw, &entry); err != nil {
|
|
t.Fatalf("decode cache: %v", err)
|
|
}
|
|
entry.CachedAtNano = time.Now().Add(-openclawDiscoveryCacheTTL - time.Second).UnixNano()
|
|
aged, err := json.Marshal(entry)
|
|
if err != nil {
|
|
t.Fatalf("encode aged cache: %v", err)
|
|
}
|
|
if err := os.WriteFile(f.cachePath(), aged, 0o600); err != nil {
|
|
t.Fatalf("write aged cache: %v", err)
|
|
}
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation past the TTL made %d CLI calls, want 2 (entry must expire)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheRejectsFutureEntry keeps a clock jump (or a
|
|
// hand-edited file) from pinning an entry that never ages out.
|
|
func TestOpenclawDiscoveryCacheRejectsFutureEntry(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
raw, err := os.ReadFile(f.cachePath())
|
|
if err != nil {
|
|
t.Fatalf("read cache: %v", err)
|
|
}
|
|
var entry openclawDiscoveryCacheEntry
|
|
if err := json.Unmarshal(raw, &entry); err != nil {
|
|
t.Fatalf("decode cache: %v", err)
|
|
}
|
|
entry.CachedAtNano = time.Now().Add(time.Hour).UnixNano()
|
|
future, err := json.Marshal(entry)
|
|
if err != nil {
|
|
t.Fatalf("encode future cache: %v", err)
|
|
}
|
|
if err := os.WriteFile(f.cachePath(), future, 0o600); err != nil {
|
|
t.Fatalf("write future cache: %v", err)
|
|
}
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation with a future-dated entry made %d CLI calls, want 2", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheIgnoresCorruptEntry covers the file a killed
|
|
// writer or a full disk can leave behind. A bad cache must degrade to slow,
|
|
// never to a failed task.
|
|
func TestOpenclawDiscoveryCacheIgnoresCorruptEntry(t *testing.T) {
|
|
for name, body := range map[string]string{
|
|
"truncated json": `{"fingerprint":{"version":1,"bin_pa`,
|
|
"empty file": ``,
|
|
"wrong shape": `["not", "an", "entry"]`,
|
|
"foreign version": `{"fingerprint":{"version":999},"cached_at_nano":1,` +
|
|
`"active_config_path":"/nope","agents_list":[]}`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
if err := os.WriteFile(f.cachePath(), []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write corrupt cache: %v", err)
|
|
}
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation with a corrupt cache made %d CLI calls, want 2 (must fall back to the CLI)", got)
|
|
}
|
|
// And the bad entry must be replaced, not left to poison every
|
|
// later task with the same fallback cost.
|
|
if got := f.run(t); got != 0 {
|
|
t.Errorf("preparation after recovery made %d CLI calls, want 0", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheSkipsFreshInstall pins the one state that must
|
|
// never be cached: "the user has no config yet" flips the moment they run
|
|
// OpenClaw's setup, and a cached miss would keep the next tasks running
|
|
// without their models and auth profiles.
|
|
func TestOpenclawDiscoveryCacheSkipsFreshInstall(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
missingPath := filepath.Join(t.TempDir(), "openclaw.json")
|
|
f.stub.responses["config file"] = openclawResponse{stdout: missingPath + "\n"}
|
|
|
|
f.run(t)
|
|
|
|
if _, err := os.Stat(f.cachePath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("fresh install wrote a cache entry (stat err = %v); it must stay uncached", err)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheSkipsFailedDiscovery keeps a transient CLI failure
|
|
// from being replayed out of cache — including the timeout this change exists
|
|
// for, which would otherwise be served instantly for the rest of the TTL.
|
|
func TestOpenclawDiscoveryCacheSkipsFailedDiscovery(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.stub.responses["config get agents.list --json"] = openclawResponse{err: errors.New("boom")}
|
|
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
if _, err := prepareOpenclawConfig(envRoot, workDir, f.prep()); err == nil {
|
|
t.Fatal("expected the agents.list failure to fail preparation")
|
|
}
|
|
|
|
if _, err := os.Stat(f.cachePath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("failed discovery wrote a cache entry (stat err = %v); failures must stay uncached", err)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheFutureDatedEntry pins both edges of the
|
|
// future-dating rule.
|
|
//
|
|
// Callers sample time.Now() once and pass it down, so on a daemon running
|
|
// several tasks at once a peer routinely commits an entry stamped after the
|
|
// reader's sample. That entry is completely valid — rejecting it just costs an
|
|
// extra discovery run, and it is what made the concurrency test below flake.
|
|
// A real clock jump still has to be caught, since an entry dated far ahead
|
|
// would otherwise never age out of its TTL.
|
|
func TestOpenclawDiscoveryCacheFutureDatedEntry(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
ahead time.Duration
|
|
want bool
|
|
}{
|
|
{"concurrent writer, microseconds ahead", 500 * time.Microsecond, true},
|
|
{"just inside the tolerance", openclawDiscoveryCacheFutureSkew - time.Millisecond, true},
|
|
{"clock jump, well past the tolerance", openclawDiscoveryCacheFutureSkew + time.Minute, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
readerNow := time.Now()
|
|
if err := storeOpenclawDiscoveryCache(f.cachePath(), f.bin, f.configPath, []any{map[string]any{"id": "scout"}}, false, readerNow.Add(tc.ahead)); err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
if _, ok := loadOpenclawDiscoveryCache(f.cachePath(), f.bin, readerNow); ok != tc.want {
|
|
t.Fatalf("entry dated %v ahead of the reader: hit = %t, want %t", tc.ahead, ok, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheConcurrentPreparations covers several tasks
|
|
// starting at once on the same daemon: the writes are atomic, so every task
|
|
// either sees a complete old entry or a complete new one, and none of them
|
|
// fails.
|
|
func TestOpenclawDiscoveryCacheConcurrentPreparations(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
|
|
const workers = 8
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var failures []error
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
mu.Lock()
|
|
failures = append(failures, err)
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
// Each worker gets its own stub-free path into discovery: the
|
|
// shared stub is not goroutine-safe, so drive the cache directly
|
|
// with the same store/load pair preparation uses.
|
|
if err := storeOpenclawDiscoveryCache(f.cachePath(), f.bin, f.configPath, []any{map[string]any{"id": "scout"}}, false, time.Now()); err != nil {
|
|
mu.Lock()
|
|
failures = append(failures, err)
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
if _, ok := loadOpenclawDiscoveryCache(f.cachePath(), f.bin, time.Now()); !ok {
|
|
mu.Lock()
|
|
failures = append(failures, errors.New("cache load failed under concurrency"))
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for _, err := range failures {
|
|
t.Errorf("concurrent cache use: %v", err)
|
|
}
|
|
|
|
// No half-written temp files may survive a clean run.
|
|
entries, err := os.ReadDir(f.cacheDir)
|
|
if err != nil {
|
|
t.Fatalf("read cache dir: %v", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.Name() != openclawDiscoveryCacheFile {
|
|
t.Errorf("leftover file in cache dir: %s", entry.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheDisabledWithoutDir pins the opt-out: with no cache
|
|
// directory (profile resolution failed), preparation still works and simply
|
|
// pays the CLI every time.
|
|
func TestOpenclawDiscoveryCacheDisabledWithoutDir(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.cacheDir = ""
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Fatalf("first preparation made %d CLI calls, want 2", got)
|
|
}
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("second preparation made %d CLI calls, want 2 (caching must stay off)", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheStoresEmptyAgentsList covers Elon's must-fix 2.
|
|
// openclawResolvedAgentsList returns (nil, false, nil) for two legitimate
|
|
// successes — a config whose agents.list is null, and an empty 2026.6+
|
|
// registry. Treating a nil list as "incomplete, do not cache" left exactly
|
|
// those users paying full discovery on every message, which is the cost this
|
|
// change exists to remove.
|
|
func TestOpenclawDiscoveryCacheStoresEmptyAgentsList(t *testing.T) {
|
|
cases := map[string]map[string]openclawResponse{
|
|
"config agents.list is null": {
|
|
"config get agents.list --json": {stdout: "null"},
|
|
},
|
|
"registry is empty": {
|
|
"config get agents.list --json": {err: errors.New("Config path not found: agents.list")},
|
|
"agents list --json": {stdout: "[]"},
|
|
},
|
|
}
|
|
for name, responses := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
for args, resp := range responses {
|
|
f.stub.responses[args] = resp
|
|
}
|
|
|
|
cold := f.run(t)
|
|
if cold == 0 {
|
|
t.Fatalf("cold preparation made no CLI calls, want a live discovery")
|
|
}
|
|
if got := f.run(t); got != 0 {
|
|
t.Errorf("warm preparation made %d CLI calls, want 0: an agent-less host must be cacheable too", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenclawDiscoveryCacheInvalidatesOnLegacyEnvChange covers must-fix 3.
|
|
// openclawFallbackConfigCandidates still honours the CLAWDBOT_* names, so a
|
|
// user who repoints those and restarts the daemon must not keep getting the
|
|
// pre-switch config served out of a cache that never looked at them.
|
|
func TestOpenclawDiscoveryCacheInvalidatesOnLegacyEnvChange(t *testing.T) {
|
|
for _, envVar := range []string{"CLAWDBOT_CONFIG_PATH", "CLAWDBOT_STATE_DIR"} {
|
|
t.Run(envVar, func(t *testing.T) {
|
|
f := newOpenclawCacheFixture(t)
|
|
f.run(t)
|
|
|
|
t.Setenv(envVar, filepath.Join(t.TempDir(), "legacy"))
|
|
|
|
if got := f.run(t); got != 2 {
|
|
t.Errorf("preparation after %s changed made %d CLI calls, want 2 (cache must be invalid)", envVar, got)
|
|
}
|
|
})
|
|
}
|
|
}
|