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