import { afterEach, describe, expect, test } from "bun:test";
import { act, useReducer } from "react";
import { testRender } from "../../../renderers/opentui/test-utils";
import { apiClient, setCloudApiFetchTransport } from "../../../api-client";
import { appReducer, createInitialState } from "../../../state/app/context";
import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
import { createDefaultConfig } from "../../../types/config";
import { ResearchSearchPane } from "./pane";
import { TestPaneProvider } from "../../../test-support/pane";
const PANE_ID = "research-search:test";
const HIT = {
id: "hit-1",
docType: "transcript",
sourceId: "call-1",
chunkIndex: 2,
ticker: "AAPL",
publishedAt: "2026-05-02T21:00:00.000Z",
title: "Apple FQ2 2026 Earnings Call",
url: "https://example.com/call-1",
snippet: "we expect gross margin to expand",
score: 4.2,
metadata: { speaker: "Tim Cook", role: "CEO", isQa: false },
};
const DOCUMENT = {
docType: "transcript",
sourceId: "call-1",
ticker: "AAPL",
title: "Apple FQ2 2026 Earnings Call",
url: "https://example.com/call-1",
publishedAt: "2026-05-02T21:00:00.000Z",
chunks: [
{
id: "chunk-0",
chunkIndex: 0,
body: "Operator opening remarks.",
metadata: { speaker: "Operator" },
},
{
id: "chunk-2",
chunkIndex: 2,
body: "we expect gross margin to expand next quarter",
metadata: { speaker: "Tim Cook", role: "CEO" },
},
],
};
let testSetup: Awaited> | undefined;
function jsonResponse(body: unknown): Response {
return {
ok: true,
status: 200,
headers: new Headers(),
text: async () => JSON.stringify(body),
} as Response;
}
const SAVED_SEARCH = {
id: "saved-1",
name: "gross margin",
query: "gross margin",
filters: { tickers: ["AAPL"], docTypes: ["transcript"] },
alertEnabled: false,
alertChannels: [],
lastRunAt: null,
lastMatchAt: "2026-05-03T09:00:00.000Z",
matchCount: 4,
createdAt: "2026-05-01T00:00:00.000Z",
};
interface RecordedRequest {
path: string;
method: string;
body?: unknown;
}
function installTransport(): { requests: RecordedRequest[] } {
const requests: RecordedRequest[] = [];
setCloudApiFetchTransport(async (url, init) => {
const parsed = new URL(String(url));
requests.push({
path: parsed.pathname,
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
if (parsed.pathname === "/cloud/search/saved") {
return jsonResponse({ searches: [SAVED_SEARCH] });
}
if (parsed.pathname.startsWith("/cloud/search/saved/")) {
return jsonResponse({ search: { ...SAVED_SEARCH, alertEnabled: true } });
}
if (parsed.pathname === "/cloud/search") {
return jsonResponse({
hits: [HIT],
total: 1,
countCapped: false,
hasMore: false,
nextOffset: 1,
tookMs: 12,
});
}
if (parsed.pathname.startsWith("/cloud/search/documents/")) {
return jsonResponse({ document: DOCUMENT });
}
return jsonResponse({});
});
return { requests };
}
function signIn(plan: "free" | "pro" = "free"): void {
apiClient.setSessionToken("test-session");
apiClient.restoreCachedUser({
id: "user-1",
name: "Test",
email: "test@example.com",
username: "test",
emailVerified: true,
plan,
effectivePlan: plan,
} as never);
}
function Harness({ mode = "results" }: { mode?: "results" | "saved" }) {
const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-research-search-test"));
initialState.focusedPaneId = PANE_ID;
initialState.paneState[PANE_ID] = {
pluginState: { "research-search": { query: "gross margin", mode } },
} as never;
const [state, dispatch] = useReducer(appReducer, initialState);
return (
);
}
async function pressKey(name: string, shift = false) {
await act(async () => {
testSetup!.renderer.keyInput.emit("keypress", {
name,
ctrl: false,
meta: false,
option: false,
shift,
eventType: "press",
repeated: false,
preventDefault: () => {},
stopPropagation: () => {},
} as never);
await testSetup!.renderOnce();
});
}
async function renderFrames(count = 6) {
for (let index = 0; index < count; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
await testSetup!.renderOnce();
});
}
}
afterEach(async () => {
if (testSetup) {
await act(async () => {
testSetup!.renderer.destroy();
});
testSetup = undefined;
}
setCloudApiFetchTransport(null);
apiClient.setSessionToken(null);
});
describe("ResearchSearchPane", () => {
test("renders a hit with the matched terms styled instead of tagged", async () => {
installTransport();
signIn();
testSetup = await testRender(, { width: 110, height: 20 });
await renderFrames();
const frame = testSetup.captureCharFrame();
expect(frame).toContain("AAPL");
expect(frame).toContain("Apple FQ2 2026 Earnings Call");
expect(frame).toContain("gross margin");
expect(frame).not.toContain("");
});
// Searching and reading a hit are free: the pane used to refuse to issue the
// request at all without Pro, which was stricter than the server ever was.
test("a free account searches and opens the document at the chunk that matched", async () => {
installTransport();
signIn();
testSetup = await testRender(, { width: 110, height: 20 });
await renderFrames();
await pressKey("return");
await renderFrames();
const frame = testSetup.captureCharFrame();
expect(frame).toContain("Tim Cook");
expect(frame).toContain("to expand next quarter");
});
// A field owns the keyboard, so Tab has to be claimed ahead of the app's
// pane cycle to reach the other field instead of leaving the pane.
test("Tab and Shift+Tab move between the query and tickers fields", async () => {
installTransport();
signIn();
testSetup = await testRender(, { width: 110, height: 20 });
await renderFrames();
// Esc clears whichever field is active, which shows where Tab landed.
await pressKey("/");
await pressKey("tab");
await pressKey("escape");
await renderFrames();
expect(testSetup.captureCharFrame()).toContain("Apple FQ2 2026 Earnings Call");
await pressKey("/");
await pressKey("tab");
await pressKey("tab", true);
await pressKey("escape");
await renderFrames();
expect(testSetup.captureCharFrame()).toContain("Type a query to search");
});
test("flips a saved-search alert and persists it", async () => {
const { requests } = installTransport();
signIn("pro");
testSetup = await testRender(, { width: 110, height: 20 });
await renderFrames();
expect(testSetup.captureCharFrame()).toContain("[ ]");
await pressKey("a");
await renderFrames();
const write = requests.find((request) => request.method === "PATCH");
expect(write?.path).toBe("/cloud/search/saved/saved-1");
expect(write?.body).toEqual({ alertEnabled: true });
expect(testSetup.captureCharFrame()).toContain("[\u2713]");
});
});