import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { act, closeBrowser, currentUrl, evalJs, getLog, openUrl, screenshot, type Cfg, } from "./browser.ts"; import { formatLog } from "./report.ts"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; // Config lives next to the extension file: ./extensions/frontend-check.json // Auto-created on first load with defaults; travels with the extension. const EXT_DIR = dirname(fileURLToPath(import.meta.url)); const CONFIG_PATH = join(EXT_DIR, "frontend-check.json"); const DEFAULTS: Cfg = { HEADLESS: true, VIEWPORT_WIDTH: 1280, VIEWPORT_HEIGHT: 900, NAV_TIMEOUT_MS: 30000, AUTO_SHOT: true, FULL_PAGE: false, SHOT_FORMAT: "jpeg", SHOT_QUALITY: 80, MAX_CONSOLE: 200, MAX_EVAL_CHARS: 4000, }; const cfg: Cfg = (() => { if (!existsSync(CONFIG_PATH)) { try { writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8"); } catch { // If we can't write (e.g. permissions), just use defaults in memory } } try { return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) }; } catch { return { ...DEFAULTS }; } })(); type Shot = { data: string; mimeType: string }; // Text + screenshot in one result: the model reads the console health line // and *sees* the rendered page in the same tool call. function withShot(text: string, shot: Shot | null) { const content: any[] = [{ type: "text", text }]; if (shot) content.push({ type: "image", data: shot.data, mimeType: shot.mimeType }); return { content, details: {} }; } async function autoShot(): Promise { if (!cfg.AUTO_SHOT) return null; try { return await screenshot(cfg); } catch { return null; // a missing screenshot should never fail the main action } } export default function (pi: ExtensionAPI) { pi.registerTool({ name: "frontend_open", label: "Frontend Open", description: "Open a frontend page in headless Chromium for testing: a dev-server URL (localhost:5173), " + "any http(s) URL, or a local HTML file path. Returns the page title, console health " + "(JS errors, failed requests), and a screenshot of the rendered page. " + "Resets the console log. Start the dev server yourself (bash, background) before opening it.", promptSnippet: "Open a frontend page headlessly and see it rendered", promptGuidelines: [ "Use frontend_open to visually verify a frontend: after editing UI code, to reproduce a reported bug, or to review a page's rendering.", "It accepts localhost URLs, plain host:port shorthand, and absolute paths to HTML files.", "The dev server must already be running — start it with bash in the background first.", "Check both halves of the result: the console summary for JS errors and the screenshot for visual problems.", ], parameters: Type.Object({ url: Type.String({ description: 'URL, host:port shorthand ("localhost:3000"), or absolute path to an HTML file', }), wait_for: Type.Optional( Type.String({ description: "CSS selector to wait for before capturing (for slow-rendering SPAs)", }), ), }), async execute(_id, params) { const text = await openUrl(cfg, params.url, params.wait_for); return withShot(text, await autoShot()); }, }); pi.registerTool({ name: "frontend_act", label: "Frontend Act", description: "Interact with the open page: click, type, press a key, hover, select an option, scroll, " + "or wait for an element. Returns the updated console health and a fresh screenshot, " + "so you can verify the UI reacted correctly.", promptSnippet: "Click, type, or scroll on the page under test", promptGuidelines: [ "frontend_act requires a page opened with frontend_open first.", 'Target elements with a CSS selector ("#id", "button[type=submit]") or visible text ("text=Submit").', "Use it to walk user flows: fill forms, open menus, trigger the interaction being tested — then judge the screenshot and console output.", "hover reveals tooltips/dropdowns; select picks an