Initial commit
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { searchPerplexity } from "../../src/search/client.js";
|
||||
import { SearchError } from "../../src/search/types.js";
|
||||
|
||||
const ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
||||
|
||||
function createSseResponse(events: Array<Record<string, unknown>>, status = 200): Response {
|
||||
const streamText = [
|
||||
...events.map((event) => `data: ${JSON.stringify(event)}\n\n`),
|
||||
"data: [DONE]\n\n",
|
||||
].join("");
|
||||
|
||||
return new Response(streamText, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("searchPerplexity", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("builds request body and headers according to protocol", async () => {
|
||||
let capturedUrl: RequestInfo | URL | undefined;
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedUrl = url;
|
||||
capturedInit = init;
|
||||
|
||||
return createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "markdown_block",
|
||||
markdown_block: {
|
||||
answer: "answer text",
|
||||
},
|
||||
},
|
||||
{
|
||||
intended_usage: "web_results",
|
||||
web_result_block: {
|
||||
web_results: [
|
||||
{
|
||||
name: "Source",
|
||||
url: "https://example.com",
|
||||
snippet: "snippet",
|
||||
timestamp: "2026-02-16T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await searchPerplexity(
|
||||
{ query: "latest bun release notes", recency: "week" },
|
||||
"jwt-token",
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(String(capturedUrl)).toBe(ENDPOINT);
|
||||
expect(capturedInit?.method).toBe("POST");
|
||||
expect(capturedInit?.signal).toBe(controller.signal);
|
||||
|
||||
const headers = new Headers(capturedInit?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer jwt-token");
|
||||
expect(headers.get("Accept")).toBe("text/event-stream");
|
||||
expect(headers.get("X-App-ApiVersion")).toBe("2.18");
|
||||
expect(headers.get("X-Request-ID")).toBeTruthy();
|
||||
|
||||
const body = JSON.parse(String(capturedInit?.body)) as {
|
||||
query_str: string;
|
||||
params: {
|
||||
query_str: string;
|
||||
mode: string;
|
||||
model_preference: string;
|
||||
is_incognito: boolean;
|
||||
search_recency_filter: string | null;
|
||||
frontend_uuid: string;
|
||||
frontend_context_uuid: string;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.query_str).toBe("latest bun release notes");
|
||||
expect(body.params.query_str).toBe("latest bun release notes");
|
||||
expect(body.params.mode).toBe("copilot");
|
||||
expect(body.params.model_preference).toBe("pplx_pro_upgraded");
|
||||
expect(body.params.is_incognito).toBe(true);
|
||||
expect(body.params.search_recency_filter).toBe("week");
|
||||
expect(body.params.frontend_uuid).toBeTruthy();
|
||||
expect(body.params.frontend_context_uuid).toBeTruthy();
|
||||
|
||||
expect(result.answer).toBe("answer text");
|
||||
expect(result.sources).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("maps 401 and 403 responses to AUTH error", async () => {
|
||||
for (const status of [401, 403]) {
|
||||
globalThis.fetch = (async () => new Response("auth fail", { status })) as unknown as typeof fetch;
|
||||
|
||||
await expect(searchPerplexity({ query: "q" }, "jwt")).rejects.toMatchObject({
|
||||
name: "SearchError",
|
||||
code: "AUTH",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("maps 429 responses to RATE_LIMIT error", async () => {
|
||||
globalThis.fetch = (async () => new Response("rate limited", { status: 429 })) as unknown as typeof fetch;
|
||||
|
||||
await expect(searchPerplexity({ query: "q" }, "jwt")).rejects.toMatchObject({
|
||||
name: "SearchError",
|
||||
code: "RATE_LIMIT",
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates sources by normalized URL", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "markdown_block",
|
||||
markdown_block: {
|
||||
answer: "answer text",
|
||||
},
|
||||
},
|
||||
{
|
||||
intended_usage: "web_results",
|
||||
web_result_block: {
|
||||
web_results: [
|
||||
{ name: "A", url: "https://example.com/path" },
|
||||
{ name: "A duplicate", url: "https://example.com/path/" },
|
||||
{ name: "B", url: "https://another.example/path" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await searchPerplexity({ query: "q" }, "jwt");
|
||||
|
||||
expect(result.sources).toHaveLength(2);
|
||||
expect(result.sources[0].url).toBe("https://example.com/path");
|
||||
expect(result.sources[1].url).toBe("https://another.example/path");
|
||||
});
|
||||
|
||||
test("answer extraction prioritizes markdown_block over ask_text and text", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
text: "fallback text",
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "ask_text",
|
||||
markdown_block: { answer: "ask text" },
|
||||
},
|
||||
{
|
||||
intended_usage: "markdown_block",
|
||||
markdown_block: { answer: "markdown answer" },
|
||||
},
|
||||
],
|
||||
sources_list: [{ title: "S", url: "https://example.com" }],
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await searchPerplexity({ query: "q" }, "jwt");
|
||||
expect(result.answer).toBe("markdown answer");
|
||||
});
|
||||
|
||||
test("answer extraction falls back to ask_text then text", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
text: "fallback text",
|
||||
blocks: [
|
||||
{
|
||||
intended_usage: "ask_text",
|
||||
markdown_block: { answer: "ask answer" },
|
||||
},
|
||||
],
|
||||
sources_list: [{ title: "S", url: "https://example.com" }],
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const askTextResult = await searchPerplexity({ query: "q" }, "jwt");
|
||||
expect(askTextResult.answer).toBe("ask answer");
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
text: "text fallback",
|
||||
sources_list: [{ title: "S", url: "https://example.com" }],
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const textResult = await searchPerplexity({ query: "q" }, "jwt");
|
||||
expect(textResult.answer).toBe("text fallback");
|
||||
});
|
||||
|
||||
test("returns EMPTY error when response has no answer and no sources", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
createSseResponse([
|
||||
{
|
||||
status: "COMPLETED",
|
||||
final: true,
|
||||
},
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await searchPerplexity({ query: "q" }, "jwt");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(SearchError);
|
||||
expect((thrown as SearchError).code).toBe("EMPTY");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { formatForLLM } from "../../src/search/format.js";
|
||||
|
||||
const NOW = Date.UTC(2026, 1, 16, 12, 0, 0);
|
||||
|
||||
describe("formatForLLM", () => {
|
||||
const originalNow = Date.now;
|
||||
|
||||
beforeEach(() => {
|
||||
Date.now = () => NOW;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = originalNow;
|
||||
});
|
||||
|
||||
test("renders required sections and deterministic source ordering", () => {
|
||||
const output = formatForLLM({
|
||||
answer: "Answer body",
|
||||
sources: [
|
||||
{
|
||||
name: "Source 1",
|
||||
url: "https://example.com/1",
|
||||
snippet: "Snippet 1",
|
||||
timestamp: new Date(NOW - 3 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
name: "Source 2",
|
||||
url: "https://example.com/2",
|
||||
snippet: "Snippet 2",
|
||||
timestamp: new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
displayModel: "pplx_pro_upgraded",
|
||||
uuid: "req-123",
|
||||
});
|
||||
|
||||
expect(output).toContain("## Answer");
|
||||
expect(output).toContain("## Sources");
|
||||
expect(output).toContain("## Meta");
|
||||
expect(output.indexOf("[1] Source 1")).toBeLessThan(output.indexOf("[2] Source 2"));
|
||||
expect(output).toContain("Provider: perplexity (oauth)");
|
||||
expect(output).toContain("Model: pplx_pro_upgraded");
|
||||
expect(output).toContain("Request ID: req-123");
|
||||
});
|
||||
|
||||
test("humanizes source ages", () => {
|
||||
const output = formatForLLM({
|
||||
answer: "Age test",
|
||||
sources: [
|
||||
{
|
||||
name: "Recent",
|
||||
url: "https://example.com/recent",
|
||||
snippet: "recent snippet",
|
||||
timestamp: new Date(NOW - 30 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
name: "Minutes",
|
||||
url: "https://example.com/minutes",
|
||||
snippet: "minutes snippet",
|
||||
timestamp: new Date(NOW - 12 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
name: "Hours",
|
||||
url: "https://example.com/hours",
|
||||
snippet: "hours snippet",
|
||||
timestamp: new Date(NOW - 5 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
name: "Days",
|
||||
url: "https://example.com/days",
|
||||
snippet: "days snippet",
|
||||
timestamp: new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(output).toContain("Recent (just now)");
|
||||
expect(output).toContain("Minutes (12m ago)");
|
||||
expect(output).toContain("Hours (5h ago)");
|
||||
expect(output).toContain("Days (3d ago)");
|
||||
});
|
||||
|
||||
test("truncates snippets to 240 chars", () => {
|
||||
const longSnippet = "x".repeat(300);
|
||||
|
||||
const output = formatForLLM({
|
||||
answer: "Snippet test",
|
||||
sources: [
|
||||
{
|
||||
name: "Long snippet",
|
||||
url: "https://example.com/long",
|
||||
snippet: longSnippet,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const snippetLine = output
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith(" ") && line.includes("..."));
|
||||
|
||||
expect(snippetLine).toBeDefined();
|
||||
expect(snippetLine!.trim().length).toBe(240);
|
||||
});
|
||||
|
||||
test("handles empty source list", () => {
|
||||
const output = formatForLLM({
|
||||
answer: "No sources",
|
||||
sources: [],
|
||||
});
|
||||
|
||||
expect(output).toContain("0 sources");
|
||||
expect(output).toContain("(no sources returned)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { mergeEvent, mergeMarkdownBlock, readSseEvents } from "../../src/search/stream.js";
|
||||
import type { StreamEvent } from "../../src/search/types.js";
|
||||
|
||||
function streamFromString(input: string, chunkSize = 8): ReadableStream<Uint8Array> {
|
||||
const encoded = new TextEncoder().encode(input);
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let index = 0; index < encoded.length; index += chunkSize) {
|
||||
controller.enqueue(encoded.slice(index, index + chunkSize));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collectEvents(stream: ReadableStream<Uint8Array>): Promise<StreamEvent[]> {
|
||||
const events: StreamEvent[] = [];
|
||||
for await (const event of readSseEvents(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("SSE stream parsing", () => {
|
||||
test("parses multiline data payloads and stops at [DONE]", async () => {
|
||||
const fixture = await Bun.file("test/fixtures/sse-basic.txt").text();
|
||||
const events = await collectEvents(streamFromString(fixture, 5));
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].status).toBe("IN_PROGRESS");
|
||||
expect(events[0].text).toBe("partial");
|
||||
expect(events[1].status).toBe("COMPLETED");
|
||||
expect(events[1].final).toBe(true);
|
||||
});
|
||||
|
||||
test("skips invalid JSON payloads and continues parsing", async () => {
|
||||
const payload = [
|
||||
"data: {invalid-json}",
|
||||
"",
|
||||
'data: {"status":"COMPLETED","text":"ok"}',
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const events = await collectEvents(streamFromString(payload));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].text).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event merging", () => {
|
||||
test("mergeMarkdownBlock splices chunks at chunk_starting_offset", () => {
|
||||
const merged = mergeMarkdownBlock(
|
||||
{
|
||||
chunks: ["Hello ", "wor"],
|
||||
chunk_starting_offset: 0,
|
||||
},
|
||||
{
|
||||
chunks: ["world"],
|
||||
chunk_starting_offset: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.chunks).toEqual(["Hello ", "world"]);
|
||||
expect(merged.answer).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("mergeEvent preserves and accumulates sources_list", () => {
|
||||
const first = mergeEvent(
|
||||
{ sources_list: [{ title: "A", url: "https://a.example" }] },
|
||||
{ text: "step 1" },
|
||||
);
|
||||
|
||||
expect(first.sources_list).toEqual([{ title: "A", url: "https://a.example" }]);
|
||||
|
||||
const second = mergeEvent(first, {
|
||||
sources_list: [{ title: "B", url: "https://b.example" }],
|
||||
status: "COMPLETED",
|
||||
});
|
||||
|
||||
expect(second.sources_list).toEqual([
|
||||
{ title: "A", url: "https://a.example" },
|
||||
{ title: "B", url: "https://b.example" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("incremental fixture merges markdown and metadata", async () => {
|
||||
const fixture = await Bun.file("test/fixtures/sse-incremental.txt").text();
|
||||
let snapshot: StreamEvent = {};
|
||||
|
||||
for await (const event of readSseEvents(streamFromString(fixture, 11))) {
|
||||
snapshot = mergeEvent(snapshot, event);
|
||||
}
|
||||
|
||||
const markdown = snapshot.blocks?.find((block) => block.intended_usage === "markdown_block")
|
||||
?.markdown_block;
|
||||
|
||||
expect(markdown?.chunks).toEqual(["Hello ", "world"]);
|
||||
expect(markdown?.answer).toBe("Hello world");
|
||||
expect(snapshot.display_model).toBe("pplx_pro_upgraded");
|
||||
expect(snapshot.uuid).toBe("req-incremental");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user