Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/web/__tests__/harness.test.ts

Raw
import { 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"]),
		);
	});
});