fix(config): apply defaults to perplexity UI

This commit is contained in:
Ivan Pereira
2026-03-21 20:54:41 +00:00
parent 11c543c0de
commit 041d30651a
13 changed files with 600 additions and 45 deletions
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerPerplexityConfigCommand } from "../src/commands/config.js";
import { loadConfig, saveConfig } from "../src/config.js";
let tempDir: string;
let configPath: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-command-test-"));
configPath = join(tempDir, "config.json");
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("perplexity-config command", () => {
test("writes selected config to disk", async () => {
let handler: ((args: string, ctx: any) => Promise<void>) | undefined;
registerPerplexityConfigCommand(
{
registerCommand(name: string, command: { handler: (args: string, ctx: any) => Promise<void> }) {
expect(name).toBe("perplexity-config");
handler = command.handler;
},
} as any,
{
getConfigPath: () => configPath,
loadConfig: () => loadConfig(configPath),
saveConfig: (config) => saveConfig(config, configPath),
},
);
expect(handler).toBeDefined();
const notifications: Array<{ message: string; level: string }> = [];
await handler!("", {
ui: {
select: async () => "GPT-5.4 (gpt54)",
confirm: async () => false,
notify: (message: string, level: string) => notifications.push({ message, level }),
},
});
const raw = await readFile(configPath, "utf8");
expect(JSON.parse(raw)).toEqual({ model: "gpt54", incognito: false });
expect(notifications).toContainEqual({
message: "Perplexity config saved:\nModel: gpt54 (GPT-5.4)\nIncognito: false",
level: "info",
});
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig, saveConfig, resolveSearchDefaults } from "../src/config.js";
let tempDir: string;
let configPath: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-perplexity-test-"));
configPath = join(tempDir, "config.json");
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("loadConfig", () => {
test("returns empty object when file is missing", async () => {
const config = await loadConfig(configPath);
expect(config).toEqual({});
});
test("returns parsed config from file", async () => {
await writeFile(configPath, JSON.stringify({ model: "gpt54", incognito: false }));
const config = await loadConfig(configPath);
expect(config.model).toBe("gpt54");
expect(config.incognito).toBe(false);
});
test("throws on invalid JSON", async () => {
await writeFile(configPath, "not json");
await expect(loadConfig(configPath)).rejects.toThrow();
});
test("throws on non-object JSON", async () => {
await writeFile(configPath, '"just a string"');
await expect(loadConfig(configPath)).rejects.toThrow("must contain a JSON object");
});
test("ignores unknown fields", async () => {
await writeFile(configPath, JSON.stringify({ model: "gpt54", unknown: true }));
const config = await loadConfig(configPath);
expect(config.model).toBe("gpt54");
expect(config).not.toHaveProperty("unknown");
});
test("ignores empty model string", async () => {
await writeFile(configPath, JSON.stringify({ model: "" }));
const config = await loadConfig(configPath);
expect(config).not.toHaveProperty("model");
});
});
describe("saveConfig", () => {
test("writes file with 0600 permissions", async () => {
await saveConfig({ model: "claude46sonnetthinking", incognito: true }, configPath);
const raw = await readFile(configPath, "utf8");
const parsed = JSON.parse(raw);
expect(parsed.model).toBe("claude46sonnetthinking");
expect(parsed.incognito).toBe(true);
const stats = await stat(configPath);
expect(stats.mode & 0o777).toBe(0o600);
});
test("creates parent directories", async () => {
const nested = join(tempDir, "a", "b", "config.json");
await saveConfig({ model: "gpt54" }, nested);
const raw = await readFile(nested, "utf8");
expect(JSON.parse(raw).model).toBe("gpt54");
});
});
describe("resolveSearchDefaults", () => {
test("returns hardcoded defaults when no config, env, or params", () => {
const result = resolveSearchDefaults({}, {});
expect(result.model).toBe("pplx_pro_upgraded");
expect(result.incognito).toBe(true);
});
test("config file values override defaults", () => {
const result = resolveSearchDefaults({}, { model: "gpt54", incognito: false });
expect(result.model).toBe("gpt54");
expect(result.incognito).toBe(false);
});
test("env var '0' disables incognito", () => {
const original = process.env.PI_PERPLEXITY_INCOGNITO;
try {
process.env.PI_PERPLEXITY_INCOGNITO = "0";
const result = resolveSearchDefaults({}, {});
expect(result.incognito).toBe(false);
} finally {
if (original === undefined) delete process.env.PI_PERPLEXITY_INCOGNITO;
else process.env.PI_PERPLEXITY_INCOGNITO = original;
}
});
test("env vars override config file", () => {
const originalModel = process.env.PI_PERPLEXITY_MODEL;
const originalIncognito = process.env.PI_PERPLEXITY_INCOGNITO;
try {
process.env.PI_PERPLEXITY_MODEL = "experimental";
process.env.PI_PERPLEXITY_INCOGNITO = "false";
const result = resolveSearchDefaults({}, { model: "gpt54", incognito: true });
expect(result.model).toBe("experimental");
expect(result.incognito).toBe(false);
} finally {
if (originalModel === undefined) delete process.env.PI_PERPLEXITY_MODEL;
else process.env.PI_PERPLEXITY_MODEL = originalModel;
if (originalIncognito === undefined) delete process.env.PI_PERPLEXITY_INCOGNITO;
else process.env.PI_PERPLEXITY_INCOGNITO = originalIncognito;
}
});
test("per-call params override everything", () => {
const originalModel = process.env.PI_PERPLEXITY_MODEL;
try {
process.env.PI_PERPLEXITY_MODEL = "experimental";
const result = resolveSearchDefaults(
{ model: "claude46sonnetthinking", incognito: false },
{ model: "gpt54", incognito: true },
);
expect(result.model).toBe("claude46sonnetthinking");
expect(result.incognito).toBe(false);
} finally {
if (originalModel === undefined) delete process.env.PI_PERPLEXITY_MODEL;
else process.env.PI_PERPLEXITY_MODEL = originalModel;
}
});
});
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
afterEach(() => {
mock.restore();
});
describe("perplexity_search execute", () => {
test("includes effective config values in the search request and result details", async () => {
const authenticate = mock(async () => "jwt-token");
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
const searchPerplexity = mock(async () => ({
answer: "answer",
sources: [{ url: "https://example.com" }],
displayModel: "gpt54",
uuid: "req-123",
}));
mock.module("../src/auth/login.js", () => ({ authenticate }));
mock.module("../src/config.js", () => ({
getConfigPath: () => "/tmp/pi-perplexity-config.json",
loadConfig,
resolveSearchDefaults,
saveConfig: mock(async () => undefined),
}));
mock.module("../src/search/client.js", () => ({ searchPerplexity }));
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
let execute: ((toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: any, ctx?: any) => Promise<any>) | undefined;
registerExtension({
registerCommand() {
return undefined;
},
registerTool(tool: { execute: typeof execute }) {
execute = tool.execute;
},
} as any);
expect(execute).toBeDefined();
const result = await execute!("tool-1", { query: "how many planets" }, undefined, undefined, { ui: {} });
expect(loadConfig).toHaveBeenCalledTimes(1);
expect(resolveSearchDefaults).toHaveBeenCalledWith({}, { model: "gpt54", incognito: false });
expect(searchPerplexity).toHaveBeenCalledWith(
{
query: "how many planets",
model: "gpt54",
incognito: false,
},
"jwt-token",
undefined,
);
expect(result.details.incognito).toBe(false);
expect(result.details.model).toBe("gpt54");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
describe("extension entrypoint", () => {
test("registers the config command", async () => {
const { default: registerExtension } = await import(`../src/index.ts?test=${crypto.randomUUID()}`);
const commands: string[] = [];
registerExtension({
registerCommand(name: string) {
commands.push(name);
},
registerTool() {
return undefined;
},
} as any);
expect(commands).toContain("perplexity-login");
expect(commands).toContain("perplexity-config");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { renderPerplexityCall } from "../src/render/call.js";
import { renderPerplexityResult } from "../src/render/result.js";
const theme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
} as any;
describe("renderPerplexityCall", () => {
test("shows the selected model in the tool call row", () => {
const rendered = renderPerplexityCall(
{
query: "latest bun release notes",
model: "claude46sonnetthinking",
recency: "week",
limit: 5,
},
theme,
).render(200).join("\n");
expect(rendered).toContain("claude46sonnetthinking");
expect(rendered).toContain("week");
expect(rendered).toContain("limit 5");
});
});
describe("renderPerplexityResult", () => {
test("shows the model in the collapsed success row", () => {
const rendered = renderPerplexityResult(
{
content: [{ type: "text", text: "Result summary" }],
details: {
model: "gpt54",
sourceCount: 3,
queryMs: 800,
},
} as any,
{ expanded: false, isPartial: false } as any,
theme,
).render(200).join("\n");
expect(rendered).toContain("gpt54");
expect(rendered).toContain("3 sources");
});
});
+48 -8
View File
@@ -51,7 +51,7 @@ describe("searchPerplexity", () => {
const controller = new AbortController();
const result = await searchPerplexity(
{ query: "latest bun release notes", recency: "week" },
{ query: "latest bun release notes", recency: "week", model: "pplx_pro_upgraded", incognito: true },
"jwt-token",
controller.signal,
);
@@ -92,11 +92,51 @@ describe("searchPerplexity", () => {
expect(result.sources).toHaveLength(1);
});
test("passes model and incognito through to request body", async () => {
let capturedInit: RequestInit | undefined;
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
capturedInit = init;
return createSseResponse([
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
]);
}) as unknown as typeof fetch;
await searchPerplexity(
{ query: "q", model: "claude46sonnetthinking", incognito: false },
"jwt-token",
);
const body = JSON.parse(String(capturedInit?.body)) as {
params: { model_preference: string; is_incognito: boolean };
};
expect(body.params.model_preference).toBe("claude46sonnetthinking");
expect(body.params.is_incognito).toBe(false);
});
test("passes incognito true through to request body", async () => {
let capturedInit: RequestInit | undefined;
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
capturedInit = init;
return createSseResponse([
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
]);
}) as unknown as typeof fetch;
await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt-token");
const body = JSON.parse(String(capturedInit?.body)) as {
params: { is_incognito: boolean };
};
expect(body.params.is_incognito).toBe(true);
});
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({
await expect(searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt")).rejects.toMatchObject({
name: "SearchError",
code: "AUTH",
});
@@ -106,7 +146,7 @@ describe("searchPerplexity", () => {
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({
await expect(searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt")).rejects.toMatchObject({
name: "SearchError",
code: "RATE_LIMIT",
});
@@ -134,7 +174,7 @@ describe("searchPerplexity", () => {
},
])) as unknown as typeof fetch;
const result = await searchPerplexity({ query: "q" }, "jwt");
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
expect(result.sources).toHaveLength(2);
expect(result.sources[0].url).toBe("https://example.com/path");
@@ -156,7 +196,7 @@ describe("searchPerplexity", () => {
},
])) as unknown as typeof fetch;
const result = await searchPerplexity({ query: "q" }, "jwt");
const result = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
expect(result.answer).toBe("markdown answer");
});
@@ -174,7 +214,7 @@ describe("searchPerplexity", () => {
},
])) as unknown as typeof fetch;
const askTextResult = await searchPerplexity({ query: "q" }, "jwt");
const askTextResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
expect(askTextResult.answer).toBe("ask answer");
globalThis.fetch = (async () =>
@@ -187,7 +227,7 @@ describe("searchPerplexity", () => {
},
])) as unknown as typeof fetch;
const textResult = await searchPerplexity({ query: "q" }, "jwt");
const textResult = await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
expect(textResult.answer).toBe("text fallback");
});
@@ -197,7 +237,7 @@ describe("searchPerplexity", () => {
let thrown: unknown;
try {
await searchPerplexity({ query: "q" }, "jwt");
await searchPerplexity({ query: "q", model: "pplx_pro_upgraded", incognito: true }, "jwt");
} catch (error) {
thrown = error;
}