311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
|
|
import { searchTinyFish } from "@oh-my-pi/pi-coding-agent/web/search/providers/tinyfish";
|
|
import { SearchProviderError } from "@oh-my-pi/pi-coding-agent/web/search/types";
|
|
|
|
const TEST_KEY = "test-tinyfish-key";
|
|
|
|
function makeAuthStorage(apiKey: string | undefined): AuthStorage {
|
|
return {
|
|
resolver(provider: string, options?: { sessionId?: string }) {
|
|
expect(provider).toBe("tinyfish");
|
|
expect(options?.sessionId).toBe("session-tinyfish-test");
|
|
return async () => apiKey;
|
|
},
|
|
hasAuth(provider: string) {
|
|
return provider === "tinyfish" && Boolean(apiKey);
|
|
},
|
|
} as unknown as AuthStorage;
|
|
}
|
|
|
|
function makeParams(query: string, authStorage: AuthStorage = makeAuthStorage(TEST_KEY)) {
|
|
return {
|
|
query,
|
|
authStorage,
|
|
systemPrompt: "TinyFish test prompt",
|
|
sessionId: "session-tinyfish-test",
|
|
} as const;
|
|
}
|
|
|
|
function getHeader(headers: RequestInit["headers"] | undefined, name: string): string | null {
|
|
if (!headers) return null;
|
|
if (headers instanceof Headers) return headers.get(name);
|
|
if (Array.isArray(headers)) {
|
|
return headers.find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? null;
|
|
}
|
|
const record = headers as Record<string, string>;
|
|
return record[name] ?? record[name.toLowerCase()] ?? null;
|
|
}
|
|
|
|
interface TinyFishMockResult {
|
|
title: string;
|
|
url: string | null;
|
|
snippet: string;
|
|
site_name?: string;
|
|
}
|
|
|
|
function tinyFishResults(prefix: string, count: number, start = 0): TinyFishMockResult[] {
|
|
return Array.from({ length: count }, (_, offset) => {
|
|
const index = start + offset;
|
|
return {
|
|
title: `${prefix} result ${index}`,
|
|
url: `https://example.com/${prefix}-${index}`,
|
|
snippet: `${prefix} snippet ${index}`,
|
|
site_name: index === 0 ? "Example Site" : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
function tinyFishPage(results: TinyFishMockResult[], page = 0, totalResults = results.length) {
|
|
return { results, total_results: totalResults, page };
|
|
}
|
|
|
|
function expectTinyFishParams(url: URL, expectedParams: readonly string[]): void {
|
|
expect([...url.searchParams.keys()].sort()).toEqual([...expectedParams].sort());
|
|
}
|
|
|
|
describe("TinyFish web search provider", () => {
|
|
it("passes TinyFish num_results and applies numSearchResults across pages", async () => {
|
|
const captured: { url: URL; init?: RequestInit }[] = [];
|
|
const pages = new Map([
|
|
["0", tinyFishResults("tinyfish", 10)],
|
|
["1", tinyFishResults("tinyfish", 10, 10)],
|
|
]);
|
|
|
|
const fetchMock: FetchImpl = async (input, init) => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push({ url, init });
|
|
const page = Number(url.searchParams.get("page") ?? 0);
|
|
return new Response(JSON.stringify(tinyFishPage(pages.get(String(page)) ?? [], page, 20)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({
|
|
...makeParams("fresh fish"),
|
|
numSearchResults: 12,
|
|
recency: "week",
|
|
fetch: fetchMock,
|
|
});
|
|
|
|
expect(captured).toHaveLength(2);
|
|
const [firstRequest, secondRequest] = captured;
|
|
const endpoint = `${firstRequest.url.origin}${firstRequest.url.pathname === "/" ? "" : firstRequest.url.pathname}`;
|
|
expect(endpoint).toBe("https://api.search.tinyfish.ai");
|
|
expect(firstRequest.init?.method ?? "GET").toBe("GET");
|
|
expect(getHeader(firstRequest.init?.headers, "X-API-Key")).toBe(TEST_KEY);
|
|
expect(firstRequest.url.searchParams.get("query")).toBe("fresh fish");
|
|
expect(firstRequest.url.searchParams.get("recency_minutes")).toBe("10080");
|
|
expect(firstRequest.url.searchParams.get("page")).toBe("0");
|
|
expect(firstRequest.url.searchParams.get("num_results")).toBe("10");
|
|
expect(secondRequest.url.searchParams.get("query")).toBe("fresh fish");
|
|
expect(secondRequest.url.searchParams.get("recency_minutes")).toBe("10080");
|
|
expect(secondRequest.url.searchParams.get("page")).toBe("1");
|
|
expect(secondRequest.url.searchParams.get("num_results")).toBe("10");
|
|
|
|
expectTinyFishParams(firstRequest.url, ["query", "recency_minutes", "num_results", "page"]);
|
|
expectTinyFishParams(secondRequest.url, ["query", "recency_minutes", "num_results", "page"]);
|
|
|
|
expect(response.provider).toBe("tinyfish");
|
|
expect(response.authMode).toBe("api_key");
|
|
expect(response.sources).toHaveLength(12);
|
|
expect(response.sources[0]).toEqual({
|
|
title: "tinyfish result 0",
|
|
url: "https://example.com/tinyfish-0",
|
|
snippet: "tinyfish snippet 0",
|
|
author: "Example Site",
|
|
});
|
|
expect(response.sources.at(-1)).toEqual({
|
|
title: "tinyfish result 11",
|
|
url: "https://example.com/tinyfish-11",
|
|
snippet: "tinyfish snippet 11",
|
|
author: undefined,
|
|
});
|
|
expect(response.sources.some(source => source.url === "https://example.com/tinyfish-12")).toBe(false);
|
|
});
|
|
|
|
it("requests two TinyFish pages for limit 20 with num_results", async () => {
|
|
const captured: URL[] = [];
|
|
const pages = new Map([
|
|
["0", tinyFishResults("limit", 10)],
|
|
["1", tinyFishResults("limit", 10, 10)],
|
|
]);
|
|
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
const page = Number(url.searchParams.get("page") ?? 0);
|
|
return new Response(JSON.stringify(tinyFishPage(pages.get(String(page)) ?? [], page, 20)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({
|
|
...makeParams("limit fish"),
|
|
limit: 20,
|
|
recency: "day",
|
|
fetch: fetchMock,
|
|
});
|
|
|
|
expect(captured).toHaveLength(2);
|
|
expect(captured.map(url => url.searchParams.get("page"))).toEqual(["0", "1"]);
|
|
for (const url of captured) {
|
|
expect(url.searchParams.get("query")).toBe("limit fish");
|
|
expect(url.searchParams.get("recency_minutes")).toBe("1440");
|
|
expect(url.searchParams.get("num_results")).toBe("10");
|
|
expectTinyFishParams(url, ["query", "recency_minutes", "num_results", "page"]);
|
|
}
|
|
|
|
expect(response.sources).toHaveLength(20);
|
|
expect(response.sources.at(-1)?.url).toBe("https://example.com/limit-19");
|
|
});
|
|
|
|
it("requests page 1 when page 0 has 10 raw results but fewer usable sources", async () => {
|
|
const captured: URL[] = [];
|
|
const firstPageResults = tinyFishResults("raw-page", 10);
|
|
firstPageResults[0] = { ...firstPageResults[0], url: null };
|
|
const pages = new Map([
|
|
["0", firstPageResults],
|
|
["1", tinyFishResults("raw-page", 10, 10)],
|
|
]);
|
|
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
const page = Number(url.searchParams.get("page") ?? 0);
|
|
return new Response(JSON.stringify(tinyFishPage(pages.get(String(page)) ?? [], page, 20)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({ ...makeParams("raw page fish"), limit: 11, fetch: fetchMock });
|
|
|
|
expect(captured.map(url => url.searchParams.get("page"))).toEqual(["0", "1"]);
|
|
expect(captured.map(url => url.searchParams.get("num_results"))).toEqual(["10", "10"]);
|
|
expect(response.sources).toHaveLength(11);
|
|
expect(response.sources[0]?.url).toBe("https://example.com/raw-page-1");
|
|
expect(response.sources.at(-1)?.url).toBe("https://example.com/raw-page-11");
|
|
});
|
|
|
|
it("stops early for limit 20 when page 0 returns fewer than 10 raw results", async () => {
|
|
const captured: URL[] = [];
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
return new Response(JSON.stringify(tinyFishPage(tinyFishResults("short-page", 9), 0, 9)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({ ...makeParams("short page fish"), limit: 20, fetch: fetchMock });
|
|
|
|
expect(captured.map(url => url.searchParams.get("page"))).toEqual(["0"]);
|
|
expect(captured[0].searchParams.get("num_results")).toBe("10");
|
|
expect(response.sources).toHaveLength(9);
|
|
expect(response.sources.at(-1)?.url).toBe("https://example.com/short-page-8");
|
|
});
|
|
|
|
it("does not request a second page for the default 10-result page", async () => {
|
|
const captured: URL[] = [];
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
return new Response(JSON.stringify(tinyFishPage(tinyFishResults("default", 10), 0, 10)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({ ...makeParams("default fish"), fetch: fetchMock });
|
|
|
|
expect(captured).toHaveLength(1);
|
|
expect(captured[0].searchParams.get("query")).toBe("default fish");
|
|
expect(captured[0].searchParams.get("page")).toBe("0");
|
|
expect(captured[0].searchParams.get("num_results")).toBe("10");
|
|
expectTinyFishParams(captured[0], ["query", "num_results", "page"]);
|
|
expect(response.sources).toHaveLength(10);
|
|
});
|
|
|
|
it("does not request a second page when the local limit is 10 or below", async () => {
|
|
const captured: URL[] = [];
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
return new Response(JSON.stringify(tinyFishPage(tinyFishResults("small-limit", 10), 0, 10)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
const response = await searchTinyFish({ ...makeParams("small limit fish"), limit: 7, fetch: fetchMock });
|
|
|
|
expect(captured).toHaveLength(1);
|
|
expect(captured[0].searchParams.get("query")).toBe("small limit fish");
|
|
expect(captured[0].searchParams.get("page")).toBe("0");
|
|
expect(captured[0].searchParams.get("num_results")).toBe("7");
|
|
expectTinyFishParams(captured[0], ["query", "num_results", "page"]);
|
|
expect(response.sources).toHaveLength(7);
|
|
expect(response.sources.at(-1)?.url).toBe("https://example.com/small-limit-6");
|
|
});
|
|
|
|
it("propagates second-page HTTP errors", async () => {
|
|
const captured: URL[] = [];
|
|
const fetchMock: FetchImpl = async input => {
|
|
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
|
|
captured.push(url);
|
|
if (url.searchParams.get("page") === "1") {
|
|
return new Response("upstream rejected page 1", { status: 402 });
|
|
}
|
|
|
|
return new Response(JSON.stringify(tinyFishPage(tinyFishResults("page-error", 10), 0, 20)), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
};
|
|
|
|
try {
|
|
await searchTinyFish({ ...makeParams("page error fish"), limit: 20, fetch: fetchMock });
|
|
expect.unreachable("expected searchTinyFish to throw");
|
|
} catch (error) {
|
|
expect(captured.map(url => url.searchParams.get("page"))).toEqual(["0", "1"]);
|
|
expect(error).toBeInstanceOf(SearchProviderError);
|
|
expect(error).toMatchObject({ provider: "tinyfish", status: 402, message: "tinyfish: 402 credits exhausted" });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[401, "tinyfish: 401 unauthorized"],
|
|
[402, "tinyfish: 402 credits exhausted"],
|
|
] as const)("maps HTTP %d to a SearchProviderError", async (status, message) => {
|
|
const fetchMock: FetchImpl = async () => new Response("upstream rejected", { status });
|
|
|
|
try {
|
|
await searchTinyFish({ ...makeParams("bad auth"), fetch: fetchMock });
|
|
expect.unreachable("expected searchTinyFish to throw");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(SearchProviderError);
|
|
expect(error).toMatchObject({ provider: "tinyfish", status, message });
|
|
}
|
|
});
|
|
|
|
it("throws a clear error when TinyFish credentials are missing", async () => {
|
|
const fetchMock: FetchImpl = async () => {
|
|
throw new Error("fetch should not be called without credentials");
|
|
};
|
|
|
|
try {
|
|
await searchTinyFish({ ...makeParams("missing creds", makeAuthStorage(undefined)), fetch: fetchMock });
|
|
expect.unreachable("expected searchTinyFish to throw");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toBe(
|
|
'TinyFish credentials not found. Set TINYFISH_API_KEY or configure an API key for provider "tinyfish".',
|
|
);
|
|
}
|
|
});
|
|
});
|