test: cover incremental content-search publishing and git-failure propagation (#2713)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-16 20:03:41 +01:00
committed by GitHub
parent bf9c5a6347
commit 032b693eb2
2 changed files with 129 additions and 0 deletions
@@ -0,0 +1,86 @@
//go:build !windows
package process
import (
"context"
"os"
"testing"
"time"
"github.com/kandev/kandev/internal/common/subproc"
)
// Content search resolves its file list from a live `git ls-files` behind the
// shared Git admission semaphore, so under contention that command can be
// starved at admission or killed at its execution deadline. Both failures must
// reach the caller as errors.
//
// A swallowed failure here would be indistinguishable from a genuinely empty
// workspace: the palette would report "No content matches found" for a file
// that exists on disk, and any test asserting on that match would fail with no
// diagnosable cause. These pin the propagation at the tracker, which is where
// the error is born and where it could be dropped; Manager.SearchWorkspaceContent
// returns the first tracker error unchanged.
// TestSearchContentReportsGitAdmissionStarvation covers a caller that gives up
// waiting for a Git slot that never frees.
func TestSearchContentReportsGitAdmissionStarvation(t *testing.T) {
tracker := newContentSearchFailureTracker(t)
restoreCap := subproc.Git().SetCapForTest(1)
t.Cleanup(restoreCap)
hold, err := subproc.AcquireGit(context.Background(), subproc.GitInteractive)
if err != nil {
t.Fatalf("hold git slot: %v", err)
}
t.Cleanup(hold)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
t.Cleanup(cancel)
results, err := tracker.SearchContent(ctx, "needle", 10)
if err == nil {
t.Fatalf("SearchContent returned nil error with %d results while Git admission "+
"was starved; a starved search must not be reported as an empty workspace",
len(results))
}
}
// TestSearchContentReportsGitCommandTimeout covers a Git process that wedges
// past gitCommandTimeout and is killed.
func TestSearchContentReportsGitCommandTimeout(t *testing.T) {
tracker := newContentSearchFailureTracker(t)
previousTimeout := gitCommandTimeout
gitCommandTimeout = 200 * time.Millisecond
t.Cleanup(func() { gitCommandTimeout = previousTimeout })
shimDir := installSleepGitShim(t)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
results, err := tracker.SearchContent(context.Background(), "needle", 10)
if err == nil {
t.Fatalf("SearchContent returned nil error with %d results after the file-list "+
"command was killed at its deadline; a failed search must not be reported "+
"as an empty workspace", len(results))
}
}
// newContentSearchFailureTracker builds a tracker over a repository holding one
// match and asserts the match is findable while Git is healthy, so a later
// empty result cannot be blamed on the fixture.
func newContentSearchFailureTracker(t *testing.T) *WorkspaceTracker {
t.Helper()
repoDir, cleanup := setupTestRepo(t)
t.Cleanup(cleanup)
writeFile(t, repoDir, "match.txt", "workspace content search needle\n")
tracker := NewWorkspaceTrackerForRepo(repoDir, "repo", newTestLogger(t))
baseline, err := tracker.SearchContent(context.Background(), "needle", 10)
if err != nil {
t.Fatalf("baseline SearchContent returned error: %v", err)
}
if len(baseline) != 1 {
t.Fatalf("baseline result count = %d, want 1", len(baseline))
}
return tracker
}
@@ -251,3 +251,46 @@ describe("useWorkspaceContentSearch stale responses", () => {
expect(result.current.isSearching).toBe(false);
});
});
describe("useWorkspaceContentSearch incremental publishing", () => {
// The retry loop exists so a repository that registers with the workspace
// process manager after task launch still contributes matches. Publishing
// the merged set only once the whole budget was spent put a fixed
// 250ms + 7 * 500ms = 3.75s floor under every search, which is what made
// the "cached preview content match" e2e spec fail its 5s assertion under
// CI load. This pins the per-attempt half of that contract: a repository
// whose matches only appear on a later attempt is published when that
// attempt lands, not when the budget runs out.
it("publishes a late repository's matches as soon as its attempt lands", async () => {
const primary = {
repository_name: "primary",
path: "src/primary.ts",
line: 1,
column: 1,
preview: "primary needle",
match_ranges: [],
};
const extra = {
repository_name: "extra",
path: "src/extra.ts",
line: 2,
column: 1,
preview: "extra needle",
match_ranges: [],
};
mockSearchWorkspaceContent
.mockResolvedValueOnce({ results: [primary] })
.mockResolvedValue({ results: [primary, extra] });
const { result } = renderHook(() =>
useWorkspaceContentSearch({ enabled: true, query: "needle", sessionId: "session-1" }),
);
await act(async () => vi.advanceTimersByTimeAsync(250));
expect(result.current.results).toEqual([primary]);
// One retry delay later the second repository has registered.
await act(async () => vi.advanceTimersByTimeAsync(500));
expect(result.current.results).toEqual([primary, extra]);
expect(result.current.isSearching).toBe(true);
});
});