Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";

Expand Down Expand Up @@ -1033,6 +1033,160 @@ describe("GraphExplorer", () => {
});
});

// ── Live search: auto re-run on filter/query change ──────────────────────────
describe("GraphExplorer live search", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

// ── (a) Toggling a type filter with an existing query auto-re-runs search ──
test("toggling a node-type filter with an existing query auto-re-runs search", async () => {
const fetchMock = makeRoutedFetch([
NODE_TYPES_ROUTE,
{ match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS },
]);
global.fetch = fetchMock;

render(<GraphExplorer workspaceSlug="test-ws" />);
await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1));

await userEvent.type(screen.getByTestId("search-input"), "processData");
await userEvent.click(screen.getByTestId("search-button"));
await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1));

// Toggling a filter with a query already present should re-fire on its own —
// no second click on the Search button.
await userEvent.click(screen.getByTestId("node-type-filter-button"));
await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));

await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(2));
expect(urlsFor(fetchMock, "/graph/nodes/search")[1]).toContain(
`types=${encodeURIComponent("Concept,Function")}`,
);
});

// ── (b) Typing debounces before firing ────────────────────────────────────
test("typing in the query box debounces before firing", async () => {
vi.useFakeTimers();
try {
const fetchMock = makeRoutedFetch([
NODE_TYPES_ROUTE,
{ match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS },
]);
global.fetch = fetchMock;

render(<GraphExplorer workspaceSlug="test-ws" />);
const input = screen.getByTestId("search-input");

fireEvent.change(input, { target: { value: "p" } });
await act(async () => {
vi.advanceTimersByTime(100);
});
fireEvent.change(input, { target: { value: "pr" } });
await act(async () => {
vi.advanceTimersByTime(100);
});
fireEvent.change(input, { target: { value: "pro" } });

// Still inside the quiet window — no request fired yet.
expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0);

await act(async () => {
vi.advanceTimersByTime(300);
});

// Only the settled value fires, and only once.
expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1);
expect(urlsFor(fetchMock, "/graph/nodes/search")[0]).toContain("q=pro");
} finally {
vi.useRealTimers();
}
});

// ── (c) Empty query doesn't fetch on filter change ────────────────────────
test("an empty query does not trigger a fetch when toggling a filter", async () => {
const fetchMock = makeRoutedFetch([NODE_TYPES_ROUTE]);
global.fetch = fetchMock;

render(<GraphExplorer workspaceSlug="test-ws" />);
await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1));

await userEvent.click(screen.getByTestId("node-type-filter-button"));
await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));
await userEvent.keyboard("{Escape}");

// No query text was ever entered — the filter click must not fetch.
expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0);
});

// ── (d) Stale/out-of-order responses don't clobber newer results ─────────
test("a slow, superseded search response does not overwrite a newer one", async () => {
const respond = (body: unknown) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
text: () => Promise.resolve(""),
});

let releaseFirst: (() => void) | undefined;
let searchCallCount = 0;

global.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/graph/node-types")) return Promise.resolve(respond(MOCK_NODE_TYPES));
if (url.includes("/graph/nodes/search")) {
searchCallCount += 1;
if (searchCallCount === 1) {
// The first (stale) request hangs until explicitly released.
return new Promise((resolve) => {
releaseFirst = () =>
resolve(
respond({
results: [
{ ref_id: "stale-1", node_type: "Concept", name: "StaleResult", description: "" },
],
}),
);
});
}
// Every later request resolves immediately with fresh results.
return Promise.resolve(
respond({
results: [
{ ref_id: "fresh-1", node_type: "Concept", name: "FreshResult", description: "" },
],
}),
);
}
return Promise.resolve(respond({}));
});

render(<GraphExplorer workspaceSlug="test-ws" />);
await userEvent.type(screen.getByTestId("search-input"), "processData");
await userEvent.click(screen.getByTestId("search-button"));
await waitFor(() => expect(searchCallCount).toBe(1));

// Toggling a filter while the first request is still in flight fires a
// second, faster request that should win.
await userEvent.click(screen.getByTestId("node-type-filter-button"));
await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));

await waitFor(() => expect(screen.getByText("FreshResult")).toBeInTheDocument());

// Now let the stale first request resolve — it must not clobber the fresh
// results that are already on screen.
releaseFirst?.();
await waitFor(() => expect(searchCallCount).toBeGreaterThanOrEqual(2));
await new Promise((r) => setTimeout(r, 0));

expect(screen.getByText("FreshResult")).toBeInTheDocument();
expect(screen.queryByText("StaleResult")).not.toBeInTheDocument();
});
});

// ── Legal recursion mock branch ───────────────────────────────────────────────
describe("Legal recursion Cypher mock branch", () => {
beforeEach(() => {
Expand Down
66 changes: 61 additions & 5 deletions src/components/graph-explorer/GraphExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,40 +662,96 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr
}, []);

// ── Semantic search (Jarvis hybrid keyword + vector) ──────────────────────
/** Guards against a slow search response overwriting a newer one. */
const searchRequestRef = useRef(0);

const runSearch = useCallback(async () => {
if (!searchQuery.trim()) return;
const q = searchQuery.trim();
if (!q) return;
const requestId = ++searchRequestRef.current;
setSearchLoading(true);
setSearchError(null);
setSearchResults([]);
setSearched(true);

try {
const params = new URLSearchParams({ q: searchQuery.trim(), limit: "25" });
const params = new URLSearchParams({ q, limit: "25" });
if (selectedTypes.length > 0) params.set("types", selectedTypes.join(","));

const res = await fetch(
`/api/workspaces/${workspaceSlug}/graph/nodes/search?${params.toString()}`
);
// A newer search superseded this one while it was in flight — drop it.
if (requestId !== searchRequestRef.current) return;

if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (requestId !== searchRequestRef.current) return;
setSearchError((data as { message?: string }).message || `Search failed (${res.status})`);
return;
}
const data: GraphSearchResponse = await res.json();
if (requestId !== searchRequestRef.current) return;
setSearchResults(Array.isArray(data?.results) ? data.results : []);
} catch (err) {
if (requestId !== searchRequestRef.current) return;
setSearchError(err instanceof Error ? err.message : "Search failed");
} finally {
setSearchLoading(false);
if (requestId === searchRequestRef.current) setSearchLoading(false);
}
}, [searchQuery, selectedTypes, workspaceSlug]);

/** Pending debounced auto-search timer, cleared before any direct trigger. */
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Previous `selectedTypes` reference, used to tell a filter click apart from typing. */
const prevSelectedTypesRef = useRef(selectedTypes);

const clearPendingSearch = useCallback(() => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = null;
}
}, []);

/** Manual trigger (Search button / Enter): cancels any pending debounce so it can't double-fire. */
const triggerSearch = useCallback(() => {
clearPendingSearch();
void runSearch();
}, [clearPendingSearch, runSearch]);

/**
* Live search: re-runs automatically whenever the type filter or the query
* text changes, as long as there's a query to search for. A filter toggle is
* a discrete click, so it re-runs immediately; free-typing is debounced so
* we don't fire a request per keystroke.
*/
useEffect(() => {
const typesChanged = prevSelectedTypesRef.current !== selectedTypes;
prevSelectedTypesRef.current = selectedTypes;

clearPendingSearch();

if (!searchQuery.trim()) return;

if (typesChanged) {
void runSearch();
return;
}

searchDebounceRef.current = setTimeout(() => {
searchDebounceRef.current = null;
void runSearch();
}, 300);

return () => clearPendingSearch();
}, [selectedTypes, searchQuery, runSearch, clearPendingSearch]);

const typeFilterLabel = typeFilterLabelFor(selectedTypes);

const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
runSearch();
triggerSearch();
}
};

Expand Down Expand Up @@ -774,7 +830,7 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr
<Button
data-testid="search-button"
variant="secondary"
onClick={runSearch}
onClick={triggerSearch}
disabled={searchLoading || !searchQuery.trim()}
>
{searchLoading ? (
Expand Down
Loading