1026 lines
35 KiB
Go
1026 lines
35 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/openclaw/gitcrawl/internal/config"
|
|
"github.com/openclaw/gitcrawl/internal/store"
|
|
)
|
|
|
|
func TestGHShimCachesReadOnlyFallbackCommands(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
echo "call-$count:$*"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GH_REPO", "cache-test/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
args := []string{"--config", configPath, "gh", "run", "view", "123", "-R", "openclaw/openclaw", "--json", "status"}
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("first cached read: %v", err)
|
|
}
|
|
first := stdout.String()
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("second cached read: %v", err)
|
|
}
|
|
if second := stdout.String(); second != first {
|
|
t.Fatalf("cached output changed: first=%q second=%q", first, second)
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "1" {
|
|
t.Fatalf("fake gh call count = %q, want 1", countData)
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats: %v", err)
|
|
}
|
|
var stats map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
|
|
}
|
|
if int(stats["entries"].(float64)) != 1 {
|
|
t.Fatalf("stats = %#v", stats)
|
|
}
|
|
counters := stats["counters"].(map[string]any)
|
|
if int(counters["backend_misses"].(float64)) != 1 || int(counters["fallback_hits"].(float64)) != 1 {
|
|
t.Fatalf("counters = %#v", counters)
|
|
}
|
|
if stats["hit_rate_percent"].(float64) != 50 {
|
|
t.Fatalf("hit rate = %#v", stats["hit_rate_percent"])
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "reset", "--json"}); err != nil {
|
|
t.Fatalf("xcache reset: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats after reset: %v", err)
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode reset stats: %v\n%s", err, stdout.String())
|
|
}
|
|
counters = stats["counters"].(map[string]any)
|
|
if int(counters["backend_misses"].(float64)) != 0 || int(counters["fallback_hits"].(float64)) != 0 {
|
|
t.Fatalf("reset counters = %#v", counters)
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "keys", "--json"}); err != nil {
|
|
t.Fatalf("xcache keys: %v", err)
|
|
}
|
|
var keys []map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &keys); err != nil {
|
|
t.Fatalf("decode keys: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(keys) != 1 || keys[0]["command"] != "run view" {
|
|
t.Fatalf("keys = %#v", keys)
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "flush", "--json"}); err != nil {
|
|
t.Fatalf("xcache flush: %v", err)
|
|
}
|
|
var flushed map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &flushed); err != nil {
|
|
t.Fatalf("decode flush: %v\n%s", err, stdout.String())
|
|
}
|
|
if int(flushed["removed"].(float64)) != 1 {
|
|
t.Fatalf("flushed = %#v", flushed)
|
|
}
|
|
}
|
|
|
|
func TestGHXCacheCommandsReportAndCleanCacheState(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
app := New()
|
|
app.configPath = configPath
|
|
var stdout bytes.Buffer
|
|
app.Stdout = &stdout
|
|
dir, err := app.ghCommandCacheDir()
|
|
if err != nil {
|
|
t.Fatalf("cache dir: %v", err)
|
|
}
|
|
now := time.Now()
|
|
freshPath := filepath.Join(dir, "fresh.json")
|
|
expiredPath := filepath.Join(dir, "expired.json")
|
|
if err := writeGHCommandCache(freshPath, ghCommandCacheEntry{CreatedAt: now.Add(-time.Minute), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
|
|
t.Fatalf("write fresh cache: %v", err)
|
|
}
|
|
if err := writeGHCommandCache(expiredPath, ghCommandCacheEntry{CreatedAt: now.Add(-8 * 24 * time.Hour), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
|
|
t.Fatalf("write expired cache: %v", err)
|
|
}
|
|
lockPath := filepath.Join(dir, "stale.lock")
|
|
if err := os.WriteFile(lockPath, []byte("123\n"), 0o600); err != nil {
|
|
t.Fatalf("write lock: %v", err)
|
|
}
|
|
old := now.Add(-3 * time.Minute)
|
|
if err := os.Chtimes(lockPath, old, old); err != nil {
|
|
t.Fatalf("age lock: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{"), 0o600); err != nil {
|
|
t.Fatalf("write broken entry: %v", err)
|
|
}
|
|
if _, ok := ghCommandCacheKeyInfoFromDirEntry(dir, mustDirEntry(t, dir, "broken.json")); ok {
|
|
t.Fatal("broken cache entry should be ignored")
|
|
}
|
|
if err := app.incrementGHXCacheCounter("local_hits"); err != nil {
|
|
t.Fatalf("increment hit: %v", err)
|
|
}
|
|
if err := app.incrementGHXCacheBackendMiss([]string{"api", "repos/openclaw/gitcrawl/actions/runs/1/jobs"}); err != nil {
|
|
t.Fatalf("increment miss: %v", err)
|
|
}
|
|
if err := app.runGHXCache([]string{"stats", "--since", "2h"}); err != nil {
|
|
t.Fatalf("stats: %v", err)
|
|
}
|
|
statsText := stdout.String()
|
|
if !strings.Contains(statsText, "hit rate") || !strings.Contains(statsText, "Backend Misses by Route") {
|
|
t.Fatalf("stats output = %q", statsText)
|
|
}
|
|
stdout.Reset()
|
|
if err := app.runGHXCache([]string{"keys"}); err != nil {
|
|
t.Fatalf("keys: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "api users/octocat") {
|
|
t.Fatalf("keys output = %q", stdout.String())
|
|
}
|
|
stdout.Reset()
|
|
if err := app.runGHXCache([]string{"snapshot", "--reset"}); err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Reset xcache counters") {
|
|
t.Fatalf("snapshot output = %q", stdout.String())
|
|
}
|
|
stdout.Reset()
|
|
if err := app.runGHXCache([]string{"gc"}); err != nil {
|
|
t.Fatalf("gc: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Removed 1 expired entrie(s), 1 stale lock(s)") {
|
|
t.Fatalf("gc output = %q", stdout.String())
|
|
}
|
|
stdout.Reset()
|
|
if err := app.runGHXCache([]string{"flush"}); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Flushed") {
|
|
t.Fatalf("flush output = %q", stdout.String())
|
|
}
|
|
if err := app.clearGHCommandCache(); err != nil {
|
|
t.Fatalf("clear cache: %v", err)
|
|
}
|
|
if err := app.runGHXCache([]string{}); err == nil {
|
|
t.Fatal("missing xcache command should fail")
|
|
}
|
|
if err := app.runGHXCache([]string{"stats", "--since", "nope"}); err == nil {
|
|
t.Fatal("invalid since should fail")
|
|
}
|
|
if err := app.runGHXCache([]string{"mystery"}); err == nil {
|
|
t.Fatal("unknown xcache command should fail")
|
|
}
|
|
}
|
|
|
|
func mustDirEntry(t *testing.T, dir, name string) os.DirEntry {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read dir: %v", err)
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.Name() == name {
|
|
return entry
|
|
}
|
|
}
|
|
t.Fatalf("missing dir entry %s", name)
|
|
return nil
|
|
}
|
|
|
|
func TestGHShimCachesGHXStyleReadOnlyFallbackCommands(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"gh", "release", "view", "v1.2.3", "-R", "openclaw/openclaw"},
|
|
{"gh", "workflow", "view", "ci.yml", "-R", "openclaw/openclaw"},
|
|
{"gh", "secret", "list", "-R", "openclaw/openclaw"},
|
|
{"gh", "variable", "list", "-R", "openclaw/openclaw"},
|
|
{"gh", "ruleset", "list", "-R", "openclaw/openclaw"},
|
|
} {
|
|
if !cacheableGHRead(args[1:]) {
|
|
t.Fatalf("%v should be cacheable", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGHShimCommandAwareCacheTTLs(t *testing.T) {
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "")
|
|
if got := ghCommandCacheTTL([]string{"api", "users/octocat"}); got != 7*24*time.Hour {
|
|
t.Fatalf("user api ttl = %s, want 7d", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "graphql", "-f", "query={ viewer { login } }"}); got != 6*time.Hour {
|
|
t.Fatalf("graphql api ttl = %s, want 6h", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"run", "view", "123", "--log"}); got != 12*time.Hour {
|
|
t.Fatalf("run log ttl = %s, want 12h", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"run", "view", "123", "--job", "456"}); got != time.Minute {
|
|
t.Fatalf("run job ttl = %s, want 1m", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"run", "list", "-R", "openclaw/openclaw"}); got != 30*time.Second {
|
|
t.Fatalf("run list ttl = %s, want 30s", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"search", "issues", "cache"}); got != 15*time.Minute {
|
|
t.Fatalf("search ttl = %s, want 15m", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "-i", "repos/openclaw/openclaw/actions/runs/123/logs"}); got != 12*time.Hour {
|
|
t.Fatalf("actions log api ttl = %s, want 12h", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/actions/runs/123"}); got != 30*time.Second {
|
|
t.Fatalf("actions run api ttl = %s, want 30s", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/pages"}); got != 30*time.Minute {
|
|
t.Fatalf("pages api ttl = %s, want 30m", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=v0.2.0"}); got != 7*24*time.Hour {
|
|
t.Fatalf("tagged contents api ttl = %s, want 7d", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=refs%2Ftags%2Fv0.2.0"}); got != 7*24*time.Hour {
|
|
t.Fatalf("refs/tags contents api ttl = %s, want 7d", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=0123456789abcdef0123456789abcdef01234567"}); got != 7*24*time.Hour {
|
|
t.Fatalf("sha contents api ttl = %s, want 7d", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=vnext"}); got != 30*time.Minute {
|
|
t.Fatalf("mutable vnext contents api ttl = %s, want 30m", got)
|
|
}
|
|
if got := ghCommandCacheTTL([]string{"api", "repos/openclaw/openclaw/contents/README.md?ref=refs%2Fheads%2Fv0.2.0"}); got != 30*time.Minute {
|
|
t.Fatalf("v-prefixed branch contents api ttl = %s, want 30m", got)
|
|
}
|
|
if got := normalizeGHAPIRoute([]string{"repos/openclaw/openclaw/actions/runs?per_page=1"}); got != "api repos/:owner/:repo/actions/runs" {
|
|
t.Fatalf("normalized actions route = %q", got)
|
|
}
|
|
if got := normalizeGHAPIRoute([]string{"--paginate", "repos/openclaw/openclaw/issues?state=all&creator=octocat", "--jq", ".[].number"}); got != "api repos/:owner/:repo/issues" {
|
|
t.Fatalf("normalized paginated issues route = %q", got)
|
|
}
|
|
if got := normalizeGHAPIRoute([]string{"repos/openclaw/openclaw/contents/.github/workflows/ci.yml?ref=main"}); got != "api repos/:owner/:repo/contents/:path" {
|
|
t.Fatalf("normalized contents route = %q", got)
|
|
}
|
|
entry := ghCommandCacheEntry{CreatedAt: time.Now().Add(-3 * time.Minute), ExitCode: 1, Stderr: "HTTP 403: API rate limit exceeded"}
|
|
if ttl := ghCommandCacheEntryTTL(entry, 12*time.Hour); ttl != 2*time.Minute {
|
|
t.Fatalf("rate-limit error ttl = %s, want 2m", ttl)
|
|
}
|
|
completedRun := ghCommandCacheEntry{
|
|
Args: []string{"run", "view", "123", "-R", "openclaw/openclaw", "--json", "status,conclusion"},
|
|
ExitCode: 0,
|
|
Stdout: `{"status":"completed","conclusion":"success"}`,
|
|
}
|
|
if ttl := ghCommandCacheEntryTTL(completedRun, 2*time.Minute); ttl != 12*time.Hour {
|
|
t.Fatalf("completed run ttl = %s, want 12h", ttl)
|
|
}
|
|
completedRuns := ghCommandCacheEntry{
|
|
Args: []string{"run", "list", "-R", "openclaw/openclaw", "--json", "status,conclusion"},
|
|
ExitCode: 0,
|
|
Stdout: `[{"status":"completed","conclusion":"success"}]`,
|
|
}
|
|
if ttl := ghCommandCacheEntryTTL(completedRuns, 2*time.Minute); ttl != 30*time.Minute {
|
|
t.Fatalf("completed run list ttl = %s, want 30m", ttl)
|
|
}
|
|
completedJobs := ghCommandCacheEntry{
|
|
Args: []string{"api", "repos/openclaw/openclaw/actions/runs/123/jobs"},
|
|
ExitCode: 0,
|
|
Stdout: `{"jobs":[{"status":"completed","conclusion":"success"}]}`,
|
|
}
|
|
if ttl := ghCommandCacheEntryTTL(completedJobs, time.Minute); ttl != 12*time.Hour {
|
|
t.Fatalf("completed jobs ttl = %s, want 12h", ttl)
|
|
}
|
|
}
|
|
|
|
func TestGHShimCanonicalizesEquivalentCacheKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
a := New()
|
|
a.configPath = configPath
|
|
t.Setenv("GH_HOST", "")
|
|
t.Setenv("GH_REPO", "")
|
|
|
|
first := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/openclaw", "--json", "status,conclusion"})
|
|
second := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "--json", "conclusion,status", "--repo", "openclaw/openclaw"})
|
|
if first != second {
|
|
t.Fatalf("equivalent command keys differ: %s != %s", first, second)
|
|
}
|
|
}
|
|
|
|
func TestGHShimGraphQLReadOnlyDetection(t *testing.T) {
|
|
if !cacheableGHRead([]string{"api", "graphql", "-f", "login=octocat", "-f", "query=query { viewer { login } }"}) {
|
|
t.Fatalf("graphql query should be cacheable")
|
|
}
|
|
if !cacheableGHRead([]string{"api", "graphql", "-f", "query={ viewer { login } }"}) {
|
|
t.Fatalf("anonymous graphql query should be cacheable")
|
|
}
|
|
if cacheableGHRead([]string{"api", "graphql", "-f", "query=mutation { addStar(input:{starrableId:\"x\"}) { clientMutationId } }"}) {
|
|
t.Fatalf("graphql mutation should not be cacheable")
|
|
}
|
|
if cacheableGHRead([]string{"api", "graphql", "-X", "PATCH", "-f", "query={ viewer { login } }"}) {
|
|
t.Fatalf("graphql non-read method should not be cacheable")
|
|
}
|
|
if cacheableGHRead([]string{"api", "graphql", "-f", "query=@query.graphql"}) {
|
|
t.Fatalf("graphql file-backed query should not be cacheable")
|
|
}
|
|
if cacheableGHRead([]string{"api", "repos/openclaw/openclaw/issues", "-f", "title=x"}) {
|
|
t.Fatalf("REST API fields should not be cacheable")
|
|
}
|
|
}
|
|
|
|
func TestGHShimExplicitCacheKeysAreCwdIndependent(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
a := New()
|
|
a.configPath = configPath
|
|
t.Setenv("GH_REPO", "")
|
|
t.Setenv("GH_HOST", "")
|
|
|
|
original, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
defer func() { _ = os.Chdir(original) }()
|
|
firstDir := t.TempDir()
|
|
secondDir := t.TempDir()
|
|
|
|
if err := os.Chdir(firstDir); err != nil {
|
|
t.Fatalf("chdir first: %v", err)
|
|
}
|
|
apiFirst := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"})
|
|
repoFirst := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"})
|
|
runFirst := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"})
|
|
implicitFirst := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
|
|
|
|
if err := os.Chdir(secondDir); err != nil {
|
|
t.Fatalf("chdir second: %v", err)
|
|
}
|
|
if apiSecond := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"}); apiSecond != apiFirst {
|
|
t.Fatalf("explicit api key changed across cwd: %s != %s", apiSecond, apiFirst)
|
|
}
|
|
if repoSecond := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}); repoSecond != repoFirst {
|
|
t.Fatalf("explicit repo key changed across cwd: %s != %s", repoSecond, repoFirst)
|
|
}
|
|
if runSecond := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"}); runSecond != runFirst {
|
|
t.Fatalf("explicit -R key changed across cwd: %s != %s", runSecond, runFirst)
|
|
}
|
|
if implicitSecond := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"}); implicitSecond == implicitFirst {
|
|
t.Fatalf("implicit repo key did not include cwd")
|
|
}
|
|
|
|
if err := os.Setenv("GH_REPO", "openclaw/other"); err != nil {
|
|
t.Fatalf("set GH_REPO: %v", err)
|
|
}
|
|
if apiWithEnv := a.ghCommandCacheKey(ctx, []string{"api", "users/octocat"}); apiWithEnv != apiFirst {
|
|
t.Fatalf("explicit api key changed across GH_REPO: %s != %s", apiWithEnv, apiFirst)
|
|
}
|
|
if repoWithEnv := a.ghCommandCacheKey(ctx, []string{"repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}); repoWithEnv != repoFirst {
|
|
t.Fatalf("explicit repo key changed across GH_REPO: %s != %s", repoWithEnv, repoFirst)
|
|
}
|
|
if runWithEnv := a.ghCommandCacheKey(ctx, []string{"run", "view", "123", "-R", "openclaw/gitcrawl", "--json", "status"}); runWithEnv != runFirst {
|
|
t.Fatalf("explicit -R key changed across GH_REPO: %s != %s", runWithEnv, runFirst)
|
|
}
|
|
}
|
|
|
|
func TestGHShimGHRepoScopedCacheKeysAreCwdIndependent(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
a := New()
|
|
a.configPath = configPath
|
|
t.Setenv("GH_HOST", "")
|
|
t.Setenv("GH_REPO", "")
|
|
|
|
original, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
defer func() { _ = os.Chdir(original) }()
|
|
|
|
if err := os.Chdir(t.TempDir()); err != nil {
|
|
t.Fatalf("chdir first: %v", err)
|
|
}
|
|
if err := os.Setenv("GH_REPO", "openclaw/one"); err != nil {
|
|
t.Fatalf("set GH_REPO one: %v", err)
|
|
}
|
|
first := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
|
|
|
|
if err := os.Chdir(t.TempDir()); err != nil {
|
|
t.Fatalf("chdir second: %v", err)
|
|
}
|
|
second := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"})
|
|
if second != first {
|
|
t.Fatalf("GH_REPO-scoped key changed across cwd: %s != %s", second, first)
|
|
}
|
|
|
|
if err := os.Setenv("GH_REPO", "openclaw/two"); err != nil {
|
|
t.Fatalf("set GH_REPO two: %v", err)
|
|
}
|
|
if otherRepo := a.ghCommandCacheKey(ctx, []string{"repo", "view", "--json", "nameWithOwner"}); otherRepo == first {
|
|
t.Fatalf("GH_REPO-scoped key ignored GH_REPO change")
|
|
}
|
|
}
|
|
|
|
func TestGHShimTracksBackendMissesByCommandAndRoute(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
ghPath := filepath.Join(dir, "gh")
|
|
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho api:$*\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_REPO", "miss-test/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
args := []string{"--config", configPath, "gh", "api", "-i", "repos/openclaw/openclaw/actions/runs/123/logs"}
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("api read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats: %v", err)
|
|
}
|
|
var stats ghCommandCacheStats
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
|
|
}
|
|
if stats.Counters.BackendMissesByCommand["api"] != 1 {
|
|
t.Fatalf("backend misses by command = %#v", stats.Counters.BackendMissesByCommand)
|
|
}
|
|
if stats.Counters.BackendMissesByRoute["api repos/:owner/:repo/actions/runs/:id/logs"] != 1 {
|
|
t.Fatalf("backend misses by route = %#v", stats.Counters.BackendMissesByRoute)
|
|
}
|
|
if stats.Counters.BackendMissesByKey["api repos/openclaw/openclaw/actions/runs/123/logs -i"] != 1 {
|
|
t.Fatalf("backend misses by key = %#v", stats.Counters.BackendMissesByKey)
|
|
}
|
|
}
|
|
|
|
func TestGHShimXCacheStatsSinceAndSnapshot(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
ghPath := filepath.Join(dir, "gh")
|
|
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho repo:$*\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_REPO", "stats-since/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
args := []string{"--config", configPath, "gh", "repo", "view", "openclaw/gitcrawl", "--json", "nameWithOwner"}
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("repo view: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--since", "1h", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats --since: %v", err)
|
|
}
|
|
var stats ghCommandCacheStats
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
|
|
}
|
|
if stats.Since != "1h0m0s" || stats.CumulativeCounters == nil || stats.Counters.BackendMisses != 1 {
|
|
t.Fatalf("since stats = %+v", stats)
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "snapshot", "--reset", "--json"}); err != nil {
|
|
t.Fatalf("xcache snapshot: %v", err)
|
|
}
|
|
var snap ghCommandCacheSnapshotResult
|
|
if err := json.Unmarshal(stdout.Bytes(), &snap); err != nil {
|
|
t.Fatalf("decode snapshot: %v\n%s", err, stdout.String())
|
|
}
|
|
if snap.SnapshotPath == "" || !snap.Reset {
|
|
t.Fatalf("snapshot result = %+v", snap)
|
|
}
|
|
if _, err := os.Stat(snap.SnapshotPath); err != nil {
|
|
t.Fatalf("snapshot file: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats after snapshot reset: %v", err)
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode reset stats: %v\n%s", err, stdout.String())
|
|
}
|
|
if stats.Counters.BackendMisses != 0 {
|
|
t.Fatalf("snapshot reset counters = %+v", stats.Counters)
|
|
}
|
|
}
|
|
|
|
func TestGHShimCachesReadOnlyFallbackErrors(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
echo "missing-$count:$*" >&2
|
|
exit 42
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GH_REPO", "error-cache/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
args := []string{"--config", configPath, "gh", "release", "view", "missing", "-R", "openclaw/openclaw"}
|
|
for i := 0; i < 2; i++ {
|
|
run := New()
|
|
var stderr bytes.Buffer
|
|
run.Stderr = &stderr
|
|
err := run.Run(ctx, args)
|
|
if err == nil {
|
|
t.Fatalf("run %d unexpectedly succeeded", i)
|
|
}
|
|
if !strings.Contains(stderr.String(), "missing-1:release view missing") {
|
|
t.Fatalf("stderr %d = %q", i, stderr.String())
|
|
}
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "1" {
|
|
t.Fatalf("fake gh call count = %q, want 1", countData)
|
|
}
|
|
}
|
|
|
|
func TestGHShimServesExpiredSuccessOnRateLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
if [ "$count" = "1" ]; then
|
|
echo "release-ok"
|
|
exit 0
|
|
fi
|
|
echo "HTTP 403: API rate limit exceeded" >&2
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
|
|
|
|
args := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
|
|
run := New()
|
|
var stdout, stderr bytes.Buffer
|
|
run.Stdout = &stdout
|
|
run.Stderr = &stderr
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("first read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("stale read should succeed: %v", err)
|
|
}
|
|
if strings.TrimSpace(stdout.String()) != "release-ok" {
|
|
t.Fatalf("stale stdout = %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "serving stale cached gh response") {
|
|
t.Fatalf("stderr missing stale warning: %q", stderr.String())
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "2" {
|
|
t.Fatalf("fake gh call count = %q, want 2", countData)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "stats", "--json"}); err != nil {
|
|
t.Fatalf("xcache stats: %v", err)
|
|
}
|
|
var stats ghCommandCacheStats
|
|
if err := json.Unmarshal(stdout.Bytes(), &stats); err != nil {
|
|
t.Fatalf("decode stats: %v\n%s", err, stdout.String())
|
|
}
|
|
if stats.Counters.StaleHits != 1 || stats.Counters.BackendMisses != 2 || stats.CacheHits != 1 || stats.TotalReads != 3 {
|
|
t.Fatalf("stats = %+v", stats)
|
|
}
|
|
}
|
|
|
|
func TestGHShimServesStaleWhileAnotherProcessRefreshes(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
if [ "$count" != "1" ]; then
|
|
sleep 1
|
|
fi
|
|
echo "release-$count"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
|
|
t.Setenv("GITCRAWL_GH_STALE_GRACE", "1h")
|
|
|
|
args := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("seed read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
|
|
var wg sync.WaitGroup
|
|
outputs := make(chan string, 2)
|
|
errs := make(chan error, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
run := New()
|
|
var out bytes.Buffer
|
|
run.Stdout = &out
|
|
if err := run.Run(ctx, args); err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
outputs <- strings.TrimSpace(out.String())
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
close(outputs)
|
|
for err := range errs {
|
|
t.Fatalf("stale while refresh run: %v", err)
|
|
}
|
|
seen := map[string]int{}
|
|
for out := range outputs {
|
|
seen[out]++
|
|
}
|
|
if seen["release-1"] != 1 || seen["release-2"] != 1 {
|
|
t.Fatalf("outputs = %#v, want one stale and one refresh", seen)
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "2" {
|
|
t.Fatalf("fake gh call count = %q, want 2", countData)
|
|
}
|
|
}
|
|
|
|
func TestGHShimMutatingFallbackClearsMatchingCacheForGHXStyleMutations(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
echo "call-$count:$*"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GH_REPO", "mutation-cache/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
readArgs := []string{"--config", configPath, "gh", "run", "view", "123", "-R", "openclaw/openclaw"}
|
|
if err := run.Run(ctx, readArgs); err != nil {
|
|
t.Fatalf("first read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, readArgs); err != nil {
|
|
t.Fatalf("second read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "run", "rerun", "123", "-R", "openclaw/openclaw"}); err != nil {
|
|
t.Fatalf("mutation: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, readArgs); err != nil {
|
|
t.Fatalf("third read: %v", err)
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "3" {
|
|
t.Fatalf("fake gh call count = %q, want 3", countData)
|
|
}
|
|
}
|
|
|
|
func TestGHShimMutatingFallbackInvalidatesTargetedTags(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
echo "call-$count:$*"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
releaseArgs := []string{"--config", configPath, "gh", "release", "view", "v1", "-R", "openclaw/openclaw"}
|
|
issueArgs := []string{"--config", configPath, "gh", "api", "repos/openclaw/openclaw/issues/12"}
|
|
for _, args := range [][]string{releaseArgs, issueArgs} {
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("seed read %v: %v", args, err)
|
|
}
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "comment", "12", "-R", "openclaw/openclaw", "--body", "fixed"}); err != nil {
|
|
t.Fatalf("mutation: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, releaseArgs); err != nil {
|
|
t.Fatalf("release should remain cached: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "call-1:") {
|
|
t.Fatalf("release cache was invalidated: %q", stdout.String())
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, issueArgs); err != nil {
|
|
t.Fatalf("issue should be refetched: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "call-4:") {
|
|
t.Fatalf("issue cache was not invalidated: %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestGHShimCachesPRDiffByHeadSHA(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
echo "diff-$count:$*"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
args := []string{"--config", configPath, "gh", "pr", "diff", "12", "-R", "openclaw/openclaw"}
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("first pr diff: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("second pr diff: %v", err)
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "1" {
|
|
t.Fatalf("fake gh call count = %q, want 1", countData)
|
|
}
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
st, err := store.Open(ctx, cfg.DBPath)
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw")
|
|
if err != nil {
|
|
t.Fatalf("repo: %v", err)
|
|
}
|
|
if _, err := st.UpsertThread(ctx, store.Thread{
|
|
RepoID: repo.ID, GitHubID: "12", Number: 12, Kind: "pull_request", State: "open",
|
|
Title: "Manifest cache update", AuthorLogin: "bob", AuthorType: "User",
|
|
HTMLURL: "https://github.com/openclaw/openclaw/pull/12", LabelsJSON: "[]", AssigneesJSON: "[]",
|
|
RawJSON: `{"head":{"sha":"def456"}}`, ContentHash: "pr-12-new", IsDraft: true,
|
|
UpdatedAtGitHub: "2026-04-27T03:00:00Z", UpdatedAt: "2026-04-27T03:00:00Z",
|
|
}); err != nil {
|
|
t.Fatalf("update pr head: %v", err)
|
|
}
|
|
if err := st.UpsertPullRequestCache(ctx, store.PullRequestDetail{
|
|
ThreadID: prIDForTest(t, ctx, st, repo.ID, 12),
|
|
RepoID: repo.ID,
|
|
Number: 12,
|
|
HeadSHA: "def456",
|
|
RawJSON: `{"head":{"sha":"def456"}}`,
|
|
FetchedAt: "2026-04-27T03:00:00Z",
|
|
UpdatedAt: "2026-04-27T03:00:00Z",
|
|
}, nil, nil, nil, nil); err != nil {
|
|
t.Fatalf("update pr cache head: %v", err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatalf("close store: %v", err)
|
|
}
|
|
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, args); err != nil {
|
|
t.Fatalf("third pr diff after head change: %v", err)
|
|
}
|
|
countData, err = os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count after update: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "2" {
|
|
t.Fatalf("fake gh call count after head update = %q, want 2", countData)
|
|
}
|
|
}
|
|
|
|
func TestGHShimXCacheGCRemovesExpiredEntries(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
ghPath := filepath.Join(dir, "gh")
|
|
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho cached:$*\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1ns")
|
|
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "run", "view", "789", "-R", "openclaw/openclaw"}); err != nil {
|
|
t.Fatalf("cached read: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
if err := run.Run(ctx, []string{"--config", configPath, "gh", "xcache", "gc", "--json"}); err != nil {
|
|
t.Fatalf("xcache gc: %v", err)
|
|
}
|
|
var result map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode gc: %v\n%s", err, stdout.String())
|
|
}
|
|
if int(result["removed"].(float64)) != 1 {
|
|
t.Fatalf("gc = %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestGHShimCoalescesConcurrentReadOnlyFallbacks(t *testing.T) {
|
|
ctx := context.Background()
|
|
configPath := seedGHShimRepo(t, ctx)
|
|
dir := t.TempDir()
|
|
countPath := filepath.Join(dir, "count")
|
|
ghPath := filepath.Join(dir, "gh")
|
|
script := `#!/bin/sh
|
|
count=0
|
|
if [ -f "$GH_SHIM_COUNT" ]; then
|
|
count=$(cat "$GH_SHIM_COUNT")
|
|
fi
|
|
count=$((count + 1))
|
|
printf "%s" "$count" > "$GH_SHIM_COUNT"
|
|
sleep 0.2
|
|
echo "call-$count:$*"
|
|
`
|
|
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake gh: %v", err)
|
|
}
|
|
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
|
t.Setenv("GH_SHIM_COUNT", countPath)
|
|
t.Setenv("GH_REPO", "coalesce-test/"+filepath.Base(dir))
|
|
t.Setenv("GITCRAWL_GH_CACHE_TTL", "1m")
|
|
|
|
args := []string{"--config", configPath, "gh", "run", "view", "456", "-R", "openclaw/openclaw", "--json", "status"}
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 2)
|
|
outputs := make(chan string, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
run := New()
|
|
var stdout bytes.Buffer
|
|
run.Stdout = &stdout
|
|
if err := run.Run(ctx, args); err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
outputs <- stdout.String()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
close(outputs)
|
|
for err := range errs {
|
|
t.Fatalf("coalesced run: %v", err)
|
|
}
|
|
if len(outputs) != 2 {
|
|
t.Fatalf("outputs = %d, want 2", len(outputs))
|
|
}
|
|
var first string
|
|
for out := range outputs {
|
|
if first == "" {
|
|
first = out
|
|
} else if out != first {
|
|
t.Fatalf("coalesced outputs differ: %q vs %q", first, out)
|
|
}
|
|
}
|
|
countData, err := os.ReadFile(countPath)
|
|
if err != nil {
|
|
t.Fatalf("read count: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(countData)) != "1" {
|
|
t.Fatalf("fake gh call count = %q, want 1", countData)
|
|
}
|
|
}
|