repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/web/__tests__/harness.test.ts
Rawimport { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { Compile } from "typebox/compile";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { createTestSession, type TestSession } from "../../../test/harness";
import { DEFAULT_TIMEOUT_MS, MAX_VISIBLE_CHARS } from "../constants";
import webExtension from "../index";
import * as search from "../search";
function theme() {
return {
fg: (_style: string, text: string) => text,
bg: (_style: string, text: string) => text,
bold: (text: string) => text,
italic: (text: string) => text,
strikethrough: (text: string) => text,
} as any;
}
function renderLines(component: { render(width: number): string[] }): string {
return component.render(120).join("\n");
}
function renderToolResult(
tool: { definition: { renderResult?: Function } },
action: string,
result: unknown,
expanded: boolean,
isPartial = false,
): string {
const component = tool.definition.renderResult?.(
result,
{ expanded, isPartial },
theme(),
{ args: { action } },
);
return component ? renderLines(component) : "";
}
describe("web extension harness", () => {
let t: TestSession;
let extension: TestSession["session"]["extensionRunner"]["extensions"][number];
beforeAll(async () => {
initTheme("default", false);
t = await createTestSession({ extensionFactories: [webExtension] });
[extension] = t.session.extensionRunner.extensions;
});
afterEach(() => vi.restoreAllMocks());
afterAll(() => t.dispose());
it("registers only web, without legacy tools or image analysis", () => {
expect([...extension.tools.keys()]).toEqual(["web"]);
});
it("bounds model-facing tool metadata", () => {
const metadata = [...extension.tools.values()].map(({ definition }) => ({
name: definition.name,
description: definition.description,
parameters: definition.parameters,
promptSnippet: definition.promptSnippet,
}));
const chars = JSON.stringify(metadata).length;
// Before consolidation: 2809 characters, using this same serialization.
expect(chars).toBeLessThan(1800);
});
it("keeps hints compact and describes action-specific defaults", () => {
const tool = extension.tools.get("web").definition;
const guidance = `${tool.description}\n${tool.promptSnippet}`;
expect(guidance.length).toBeLessThan(300);
expect(guidance).not.toMatch(
/Parameters:|Routes by Content-Type|Codex|DuckDuckGo/,
);
expect(tool.description).toContain("metadata");
expect(tool.description).toContain("without page bodies");
const props = tool.parameters.properties;
expect(props.format.description).toContain("json is Pandoc AST for HTML");
expect(props.format.description).toContain("raw skips conversion");
expect(props.linesMatching.description).toContain("Literal substrings");
expect(props.contextLines.description).toContain("max 100");
expect(props.maxChars.description).toContain(`max ${MAX_VISIBLE_CHARS}`);
expect(props.timeout.description).toContain(
`default ${DEFAULT_TIMEOUT_MS}`,
);
expect(props.maxUrls.description).toContain("default 1000");
expect(props.maxSitemaps.description).toContain("default 20");
});
it("exposes a flat, provider-compatible schema with a required action", () => {
const schema = extension.tools.get("web").definition.parameters;
expect(schema.required).toEqual(["action"]);
expect(schema.properties.action.enum).toEqual(["fetch", "map", "search"]);
expect(schema.properties.format.enum).toEqual([
"markdown",
"text",
"html",
"json",
"raw",
]);
expect(Object.keys(schema.properties).sort()).toEqual([
"action",
"caseSensitive",
"contextLines",
"count",
"format",
"linesMatching",
"maxBytes",
"maxChars",
"maxSitemaps",
"maxUrls",
"query",
"timeout",
"url",
]);
const validator = Compile(schema);
for (const action of ["fetch", "map", "search"])
expect(validator.Check({ action })).toBe(true);
for (const args of [
{},
{ action: "image" },
{ action: 123 },
{ action: "fetch", image_source: "cat.png" },
{ action: "search", format: "unsupported" },
])
expect(validator.Check(args)).toBe(false);
});
it.each([
["maxChars", 100, MAX_VISIBLE_CHARS],
["maxBytes", 1000, 100_000_000],
["contextLines", 0, 100],
["timeout", 5000, 600_000],
["maxUrls", 1, 10_000],
["maxSitemaps", 1, 100],
["count", 1, 20],
] as const)("preserves %s numeric bounds", (field, minimum, maximum) => {
const validator = Compile(
extension.tools.get("web").definition.parameters.properties[field],
);
for (const value of [minimum, maximum])
expect(validator.Check(value)).toBe(true);
for (const value of [minimum - 1, maximum + 1, "1"])
expect(validator.Check(value)).toBe(false);
});
it.each([
{ action: "fetch" },
{ action: "map" },
{ action: "search" },
{ action: "fetch", url: 123 },
{ action: "map", url: 123 },
{ action: "search", query: 123 },
{
action: "fetch",
url: "https://example.com",
maxChars: MAX_VISIBLE_CHARS + 1,
},
{ action: "search", query: "docs", format: "markdown" },
{ action: "fetch", url: "https://example.com", count: 5 },
{ action: "map", url: "https://example.com", format: "text" },
{ action: "search", query: "docs", maxChars: 1000 },
{ action: "understand_image" },
{ action: "toString" },
{},
])("rejects invalid action arguments before I/O: %j", async (args) => {
const network = vi.spyOn(globalThis, "fetch");
await expect(
extension.tools
.get("web")
.definition.execute("invalid", args, undefined, undefined, {}),
).rejects.toThrow(/arguments|Expected action/);
expect(network).not.toHaveBeenCalled();
});
it("activates web while preserving unrelated active tools", async () => {
t.session.setActiveToolsByName(["read"]);
await extension.handlers.get("session_start")[0](
{ type: "session_start", reason: "startup" },
t.session.extensionRunner.createCommandContext(),
);
expect(t.session.getActiveToolNames()).toEqual(["read", "web"]);
});
it.each(["text", "json"])(
"dispatches search with %s output, progress, context, and cancellation",
async (format) => {
const results = [
{
title: "Quick Select Mode",
url: "https://wezterm.org/quickselect.html",
snippet: "Select text",
},
];
const backend = vi
.spyOn(search, "searchWeb")
.mockResolvedValue({ results, source: "codex" });
const signal = new AbortController().signal;
const update = vi.fn();
const ctx = { cwd: t.cwd };
const result = await extension.tools.get("web").definition.execute(
"search",
{
action: "search",
query: "wezterm",
count: 1,
timeout: 5000,
format,
},
signal,
update,
ctx,
);
expect(backend).toHaveBeenCalledWith({
query: "wezterm",
count: 1,
timeout: 5000,
signal,
ctx,
});
expect(update).toHaveBeenCalled();
expect(result.details.results).toEqual(results);
expect(result.content[0].text).toContain("Quick Select Mode");
expect(result.content[0].text).toContain("untrusted");
if (format === "json")
expect(result.content[0].text).toContain('"query": "wezterm"');
},
);
it.each(["text", "json"])(
"preserves uncapped Parallel excerpts in %s output",
async (format) => {
const snippet = "long excerpt ".repeat(3000) + "END OF EXCERPT";
vi.spyOn(search, "searchWeb").mockResolvedValue({
source: "parallel",
results: [{ title: "Docs", url: "https://example.com/docs", snippet }],
});
const result = await extension.tools
.get("web")
.definition.execute(
"parallel",
{ action: "search", query: "docs", format },
undefined,
undefined,
{},
);
expect(result.content[0].text).toContain(snippet);
expect(result.details.source).toBe("parallel");
},
);
it("propagates search errors", async () => {
vi.spyOn(search, "searchWeb").mockRejectedValue(
new Error("Search unavailable"),
);
await expect(
extension.tools
.get("web")
.definition.execute(
"search",
{ action: "search", query: "docs" },
undefined,
undefined,
{},
),
).rejects.toThrow("Search unavailable");
});
it.each(["fetch", "map", "search"])(
"routes %s partial rendering using row-local args",
(action) => {
const tool = extension.tools.get("web");
const text = renderToolResult(
tool,
action,
{ content: [], details: {} },
false,
true,
);
expect(text).toMatch(/Fetching|Mapping|Searching/);
const call = tool.definition.renderCall(
{ action, url: "https://example.com", query: "docs" },
theme(),
{},
);
expect(renderLines(call)).toContain(`web ${action}`);
},
);
it("renders incomplete calls and validation failures safely", () => {
const tool = extension.tools.get("web").definition;
expect(renderLines(tool.renderCall({}, theme(), {}))).toContain("web");
const rendered = tool.renderResult(
{ content: [{ type: "text", text: "Invalid arguments" }] },
{ expanded: false },
theme(),
{ args: {}, isError: true },
);
expect(renderLines(rendered)).toContain("Invalid arguments");
});
it("keeps fetch preview hidden until expanded", () => {
const tool = extension.tools.get("web");
const result = {
content: [{ type: "text", text: "full saved content" }],
details: {
kind: "html",
format: "markdown",
contentType: "text/html",
bodySize: 261_200,
chars: 10_000,
visibleChars: 2270,
responseId: "saved-id",
preview: "# Quick Select\nVerbose page body",
},
};
const collapsed = renderToolResult(tool, "fetch", result, false);
const expanded = renderToolResult(tool, "fetch", result, true);
expect(collapsed).toContain("Agent sees:");
expect(collapsed).not.toContain("Quick Select");
expect(collapsed).not.toContain("Verbose page body");
expect(expanded).toContain("Quick Select");
expect(expanded).toContain("Verbose page body");
});
it("shows fetch visible content on expand without a preview", () => {
const tool = extension.tools.get("web");
const result = {
content: [
{
type: "text",
text: "https://wezterm.org/quickselect.html\n\n# Quick Select Mode\nExpanded markdown body",
},
],
details: {
kind: "html",
format: "markdown",
contentType: "text/html",
bodySize: 261_200,
chars: 10_000,
visibleChars: 1014,
responseId: "saved-id",
},
};
const collapsed = renderToolResult(tool, "fetch", result, false);
const expanded = renderToolResult(tool, "fetch", result, true);
expect(collapsed).toContain("to expand");
expect(collapsed).not.toContain("Quick Select Mode");
expect(expanded).toContain("Quick Select Mode");
expect(expanded).toContain("Expanded markdown body");
});
it("keeps search result titles hidden until expanded", () => {
const tool = extension.tools.get("web");
const result = {
content: [
{
type: "text",
text: "Search: wezterm\n\n[1] Quick Select Mode\n https://wezterm.org/quickselect.html",
},
],
details: {
query: "wezterm",
source: "codex",
results: [
{
title: "Quick Select Mode",
url: "https://wezterm.org/quickselect.html",
},
],
},
};
const collapsed = renderToolResult(tool, "search", result, false);
const expanded = renderToolResult(tool, "search", result, true);
expect(collapsed).toContain("1 result");
expect(collapsed).toContain("codex");
expect(collapsed).toContain("to expand");
expect(collapsed).not.toContain("Quick Select Mode");
expect(expanded).toContain("Quick Select Mode");
expect(expanded).not.toContain("to expand");
});
it("reveals the saved map path only when expanded", () => {
const tool = extension.tools.get("web");
const result = {
content: [{ type: "text", text: "https://example.com/docs" }],
details: {
urlCount: 1,
sitemapCount: 1,
responseId: "saved-id",
fullOutputPath: "/tmp/web-map.json",
},
};
const collapsed = renderToolResult(tool, "map", result, false);
const expanded = renderToolResult(tool, "map", result, true);
expect(collapsed).toContain("to expand");
expect(collapsed).not.toContain("/tmp/web-map.json");
expect(collapsed).not.toContain("https://example.com/docs");
expect(expanded).toContain("/tmp/web-map.json");
expect(expanded).toContain("https://example.com/docs");
expect(expanded).not.toContain("to expand");
});
it("keeps long errors compact until expanded", () => {
const tool = extension.tools.get("web").definition;
const marker = "END-OF-WEB-DIAGNOSTIC";
const result = {
content: [
{
type: "text",
text: `Request failed: ${"detail ".repeat(40)}${marker}`,
},
],
};
const collapsed = renderLines(
tool.renderResult(result, { expanded: false }, theme(), {
args: { action: "search" },
isError: true,
}),
);
const expanded = renderLines(
tool.renderResult(result, { expanded: true }, theme(), {
args: { action: "search" },
isError: true,
}),
);
expect(collapsed).toContain("to expand");
expect(collapsed).not.toContain(marker);
expect(expanded).toContain(marker);
});
});
describe("web debug harness", () => {
let debugSession: TestSession | undefined;
afterEach(() => {
debugSession?.dispose();
debugSession = undefined;
});
it("writes debug lifecycle records in its sandbox when enabled", async () => {
const extensionPath = join(import.meta.dirname, "../index.ts");
debugSession = await createTestSession({
env: { PI_WEB_DEBUG: "1" },
extensions: [extensionPath],
});
const extension = debugSession.session.extensionRunner.extensions[0];
const context = debugSession.session.extensionRunner.createCommandContext();
for (const handler of extension.handlers.get("session_start"))
await handler({ type: "session_start" }, context);
for (const handler of extension.handlers.get("session_shutdown"))
await handler({ type: "session_shutdown" }, context);
const directory = join(
debugSession.cwd,
".test-state",
"pi-ext",
"debug",
"web",
);
const records = readFileSync(
join(directory, readdirSync(directory)[0]),
"utf8",
)
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(records.map((record) => record.event)).toEqual(
expect.arrayContaining(["session.start", "session.shutdown"]),
);
});
});