Files
pi-skill/extensions/web-test.ts
2026-05-25 16:41:08 +07:00

706 lines
22 KiB
TypeScript

// ABOUTME: Remote web testing extension using Cloudflare Browser Rendering for screenshots, content extraction, and a11y.
// ABOUTME: Registers /web-remote command and web_remote tool backed by a deployed Cloudflare Worker.
// ABOUTME: REMOTE ONLY — cannot access localhost, 127.0.0.1, or local network. Use agent-browser skill for local testing.
/**
* Web Remote -- Cloudflare Browser Rendering powered REMOTE web testing
*
* IMPORTANT: This is a REMOTE service. It CANNOT access localhost, 127.0.0.1,
* or any local network address. For local testing, use the agent-browser skill instead.
*
* Uses a deployed Cloudflare Worker (pi-web-test) with Browser Rendering
* binding to provide headless browser capabilities:
*
* - Screenshot any URL at custom viewport sizes
* - Extract page text/HTML content (with optional CSS selector)
* - Run accessibility audits via axe-core
* - Capture responsive screenshots at mobile/tablet/desktop breakpoints
*
* Screenshots are saved to .pi/web-test-captures/ and paths are returned
* so the agent can Read them to visually inspect pages.
*
* Commands:
* /web-remote screenshot <url> -- capture a screenshot
* /web-remote content <url> [selector] -- extract page content
* /web-remote a11y <url> -- accessibility audit
* /web-remote responsive <url> -- multi-viewport screenshots
*
* Tool:
* web_remote -- programmatic access (agent can call)
*
* Prerequisites:
* - Cloudflare Worker deployed (auto-deployed on first use)
* - wrangler CLI authenticated
* - API key in agent/extensions/web-test-worker/.env
*
* Usage: pi -e extensions/web-test.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { type AutocompleteItem } from "@mariozechner/pi-tui";
import { Text } from "@mariozechner/pi-tui";
import { execSync } from "child_process";
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
// ── Constants ────────────────────────────────────
const CAPTURE_DIR_NAME = "web-test-captures";
const WORKER_NAME = "pi-web-test";
// ── Types ────────────────────────────────────────
type Action = "screenshot" | "content" | "a11y" | "responsive";
interface WorkerConfig {
workerUrl: string;
apiKey: string;
}
interface WebTestResult {
action: Action;
url: string;
success: boolean;
screenshots?: string[];
data?: any;
error?: string;
elapsed: number;
}
// ── Config Loading ───────────────────────────────
function loadWorkerConfig(): WorkerConfig | null {
const extDir = dirname(fileURLToPath(import.meta.url));
const envPath = join(extDir, "web-test-worker", ".env");
if (!existsSync(envPath)) {
return null;
}
const content = readFileSync(envPath, "utf-8");
const vars: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
}
if (!vars.WORKER_URL || !vars.API_KEY) {
return null;
}
return { workerUrl: vars.WORKER_URL, apiKey: vars.API_KEY };
}
// ── Capture Directory ────────────────────────────
function ensureCaptureDir(cwd: string): string {
const captureDir = join(cwd, ".pi", CAPTURE_DIR_NAME);
if (!existsSync(captureDir)) {
mkdirSync(captureDir, { recursive: true });
}
return captureDir;
}
function timestamp(): string {
const now = new Date();
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
// ── Worker Deployment ────────────────────────────
function checkWorkerHealth(config: WorkerConfig): boolean {
try {
const result = execSync(
`curl -sf --max-time 5 "${config.workerUrl}/ping"`,
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
);
const parsed = JSON.parse(result);
return parsed.status === "ok";
} catch {
return false;
}
}
function deployWorker(): { success: boolean; url?: string; error?: string } {
const extDir = dirname(fileURLToPath(import.meta.url));
const workerDir = join(extDir, "web-test-worker");
if (!existsSync(join(workerDir, "node_modules"))) {
try {
execSync("npm install", { cwd: workerDir, stdio: "ignore", timeout: 60000 });
} catch (e: any) {
return { success: false, error: `npm install failed: ${e.message}` };
}
}
try {
const output = execSync("npx wrangler deploy 2>&1", {
cwd: workerDir,
encoding: "utf-8",
timeout: 60000,
});
// Extract URL from deploy output
const urlMatch = output.match(/https:\/\/[\w-]+\.[\w-]+\.workers\.dev/);
if (urlMatch) {
return { success: true, url: urlMatch[0] };
}
return { success: true, url: undefined };
} catch (e: any) {
return { success: false, error: `wrangler deploy failed: ${e.stdout || e.message}` };
}
}
// ── Worker API Calls ─────────────────────────────
async function callWorker(
config: WorkerConfig,
endpoint: string,
body: Record<string, any>,
): Promise<Response> {
const resp = await fetch(`${config.workerUrl}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": config.apiKey,
},
body: JSON.stringify(body),
});
return resp;
}
// ── Action Handlers ──────────────────────────────
async function doScreenshot(
config: WorkerConfig,
url: string,
cwd: string,
opts: { width?: number; height?: number; fullPage?: boolean },
): Promise<WebTestResult> {
const start = Date.now();
const resp = await callWorker(config, "/screenshot", {
url,
width: opts.width ?? 1280,
height: opts.height ?? 720,
fullPage: opts.fullPage ?? false,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText })) as any;
return { action: "screenshot", url, success: false, error: err.error || resp.statusText, elapsed: Date.now() - start };
}
const captureDir = ensureCaptureDir(cwd);
const ts = timestamp();
const filename = `screenshot-${ts}.png`;
const filePath = join(captureDir, filename);
const buffer = Buffer.from(await resp.arrayBuffer());
writeFileSync(filePath, buffer);
const title = decodeURIComponent(resp.headers.get("X-Page-Title") || "untitled");
return {
action: "screenshot",
url,
success: true,
screenshots: [filePath],
data: { title, width: opts.width ?? 1280, height: opts.height ?? 720, sizeBytes: buffer.length },
elapsed: Date.now() - start,
};
}
async function doContent(
config: WorkerConfig,
url: string,
opts: { selector?: string },
): Promise<WebTestResult> {
const start = Date.now();
const resp = await callWorker(config, "/content", { url, selector: opts.selector });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText })) as any;
return { action: "content", url, success: false, error: err.error || resp.statusText, elapsed: Date.now() - start };
}
const data = await resp.json();
return {
action: "content",
url,
success: true,
data,
elapsed: Date.now() - start,
};
}
async function doA11y(
config: WorkerConfig,
url: string,
): Promise<WebTestResult> {
const start = Date.now();
const resp = await callWorker(config, "/a11y", { url });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText })) as any;
return { action: "a11y", url, success: false, error: err.error || resp.statusText, elapsed: Date.now() - start };
}
const data = await resp.json();
return {
action: "a11y",
url,
success: true,
data,
elapsed: Date.now() - start,
};
}
async function doResponsive(
config: WorkerConfig,
url: string,
cwd: string,
opts: { viewports?: Array<{ name: string; width: number; height: number }> },
): Promise<WebTestResult> {
const start = Date.now();
const resp = await callWorker(config, "/responsive", {
url,
viewports: opts.viewports,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText })) as any;
return { action: "responsive", url, success: false, error: err.error || resp.statusText, elapsed: Date.now() - start };
}
const data = await resp.json() as any;
// Save each screenshot as a separate PNG
const captureDir = ensureCaptureDir(cwd);
const ts = timestamp();
const savedPaths: string[] = [];
if (data.screenshots && Array.isArray(data.screenshots)) {
for (const shot of data.screenshots) {
const filename = `responsive-${shot.name}-${ts}.png`;
const filePath = join(captureDir, filename);
const buffer = Buffer.from(shot.base64, "base64");
writeFileSync(filePath, buffer);
savedPaths.push(filePath);
}
}
return {
action: "responsive",
url,
success: true,
screenshots: savedPaths,
data: {
title: data.title,
viewports: data.viewports,
},
elapsed: Date.now() - start,
};
}
// ── Result Formatting ────────────────────────────
function formatResult(result: WebTestResult): string {
const lines: string[] = [];
if (!result.success) {
lines.push(`Error: ${result.error}`);
lines.push(`URL: ${result.url}`);
lines.push(`Elapsed: ${Math.round(result.elapsed / 1000)}s`);
return lines.join("\n");
}
lines.push(`Web test complete: ${result.action}`);
lines.push(`URL: ${result.url}`);
lines.push(`Elapsed: ${Math.round(result.elapsed / 1000)}s`);
lines.push("");
switch (result.action) {
case "screenshot": {
const d = result.data;
lines.push(`Page title: ${d.title}`);
lines.push(`Viewport: ${d.width}x${d.height}`);
lines.push(`File size: ${(d.sizeBytes / 1024).toFixed(1)} KB`);
lines.push("");
if (result.screenshots?.length) {
lines.push("Screenshot saved:");
for (const p of result.screenshots) lines.push(` ${p}`);
lines.push("");
lines.push("Use Read on the path above to view the captured page.");
}
break;
}
case "content": {
const d = result.data as any;
lines.push(`Page title: ${d.title}`);
lines.push(`Text length: ${d.textLength} chars`);
lines.push(`HTML length: ${d.htmlLength} chars`);
lines.push("");
lines.push("--- Page Text ---");
// Truncate for display
const text = d.text as string;
lines.push(text.length > 2000 ? text.slice(0, 2000) + "\n...[truncated]" : text);
break;
}
case "a11y": {
const d = result.data as any;
lines.push(`Page title: ${d.title}`);
lines.push("");
lines.push(`Summary:`);
lines.push(` Violations: ${d.summary.violations}`);
lines.push(` Passes: ${d.summary.passes}`);
lines.push(` Incomplete: ${d.summary.incomplete}`);
lines.push(` Inapplicable: ${d.summary.inapplicable}`);
if (d.violations && d.violations.length > 0) {
lines.push("");
lines.push("Violations:");
for (const v of d.violations) {
lines.push(` [${v.impact}] ${v.id}: ${v.description}`);
lines.push(` Help: ${v.help}`);
lines.push(` Affected nodes: ${v.nodes}`);
lines.push(` More info: ${v.helpUrl}`);
lines.push("");
}
} else {
lines.push("");
lines.push("No accessibility violations found.");
}
break;
}
case "responsive": {
const d = result.data as any;
lines.push(`Page title: ${d.title}`);
lines.push("");
if (d.viewports && d.viewports.length > 0) {
lines.push("Viewports captured:");
for (const vp of d.viewports) {
lines.push(` ${vp.name}: ${vp.width}x${vp.height}`);
}
}
if (result.screenshots?.length) {
lines.push("");
lines.push("Screenshots saved:");
for (const p of result.screenshots) lines.push(` ${p}`);
lines.push("");
lines.push("Use Read on any path above to view the captured page.");
}
break;
}
}
return lines.join("\n");
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
let config: WorkerConfig | null = null;
function getConfig(): WorkerConfig | null {
if (config) return config;
config = loadWorkerConfig();
return config;
}
function ensureWorker(): { config: WorkerConfig | null; error?: string } {
const cfg = getConfig();
if (!cfg) {
return {
config: null,
error: "Worker not configured. Missing .env file at agent/extensions/web-test-worker/.env with WORKER_URL and API_KEY.",
};
}
// Quick health check
if (!checkWorkerHealth(cfg)) {
// Try redeploying
const result = deployWorker();
if (!result.success) {
return { config: null, error: `Worker health check failed and redeploy failed: ${result.error}` };
}
if (result.url && result.url !== cfg.workerUrl) {
cfg.workerUrl = result.url;
}
}
return { config: cfg };
}
// ── /web-test command ────────────────────────
const ACTIONS = ["screenshot", "content", "a11y", "responsive"];
pi.registerCommand("web-remote", {
description: "Test REMOTE web pages using Cloudflare Browser Rendering (screenshot, content, a11y, responsive). CANNOT access localhost — use agent-browser for local testing.",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const items = ACTIONS.map(a => ({
value: a,
label: a === "screenshot" ? "screenshot <url> -- capture a PNG screenshot"
: a === "content" ? "content <url> [selector] -- extract page text/HTML"
: a === "a11y" ? "a11y <url> -- accessibility audit via axe-core"
: "responsive <url> -- multi-viewport screenshots",
}));
const filtered = items.filter(i => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : items;
},
handler: async (args, ctx) => {
const parts = (args ?? "").trim().split(/\s+/);
const action = parts[0]?.toLowerCase();
const url = parts[1];
if (!action || !ACTIONS.includes(action)) {
ctx.ui.notify(
"Usage: /web-remote <action> <url>\n" +
"Actions: screenshot, content, a11y, responsive\n" +
"NOTE: Remote only — cannot access localhost. Use agent-browser for local testing.",
"warning",
);
return;
}
if (!url) {
ctx.ui.notify(`Usage: /web-remote ${action} <url>`, "warning");
return;
}
const { config: cfg, error } = ensureWorker();
if (!cfg) {
ctx.ui.notify(error!, "error");
return;
}
ctx.ui.notify(`Running ${action} on ${url}...`, "info");
let result: WebTestResult;
switch (action) {
case "screenshot":
result = await doScreenshot(cfg, url, ctx.cwd, {});
break;
case "content":
result = await doContent(cfg, url, { selector: parts[2] });
break;
case "a11y":
result = await doA11y(cfg, url);
break;
case "responsive":
result = await doResponsive(cfg, url, ctx.cwd, {});
break;
default:
return;
}
if (result.success) {
const msg = result.screenshots?.length
? `${action} complete (${Math.round(result.elapsed / 1000)}s). ${result.screenshots.length} file(s) saved.`
: `${action} complete (${Math.round(result.elapsed / 1000)}s).`;
ctx.ui.notify(msg, "success");
} else {
ctx.ui.notify(`${action} failed: ${result.error}`, "error");
}
return formatResult(result);
},
});
// ── web_remote tool ──────────────────────────
pi.registerTool({
name: "web_remote",
label: "Web Remote",
description: [
"Test REMOTE web pages using Cloudflare Browser Rendering.",
"IMPORTANT: This is a REMOTE service — it CANNOT access localhost, 127.0.0.1,",
"or any local network address. For localhost testing, use the agent-browser skill",
"(via Bash: agent-browser open <url>, agent-browser snapshot -i, etc.).",
"",
"Captures screenshots, extracts content, runs accessibility audits,",
"and tests responsive layouts via a remote headless Chromium browser.",
"",
"Actions:",
" screenshot -- capture a PNG screenshot (returns file path for Read tool)",
" content -- extract page text and HTML (with optional CSS selector)",
" a11y -- run axe-core accessibility audit",
" responsive -- capture at mobile (375px), tablet (768px), desktop (1440px)",
"",
"Screenshot paths can be passed to the Read tool to visually inspect pages.",
].join("\n"),
parameters: Type.Object({
action: Type.String({
description: "Action to perform: screenshot, content, a11y, responsive",
}),
url: Type.String({
description: "URL to test (must be http: or https:)",
}),
width: Type.Optional(Type.Number({ description: "Viewport width in pixels (default: 1280, screenshot only)" })),
height: Type.Optional(Type.Number({ description: "Viewport height in pixels (default: 720, screenshot only)" })),
fullPage: Type.Optional(Type.Boolean({ description: "Capture full page scroll (default: false, screenshot only)" })),
selector: Type.Optional(Type.String({ description: "CSS selector to extract (content action only)" })),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { action, url, width, height, fullPage, selector } =
params as { action: string; url: string; width?: number; height?: number; fullPage?: boolean; selector?: string };
// Validate action
if (!ACTIONS.includes(action)) {
return {
content: [{ type: "text" as const, text: `Unknown action: ${action}. Available: ${ACTIONS.join(", ")}` }],
details: { error: `Unknown action: ${action}` },
};
}
// Validate URL
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return {
content: [{ type: "text" as const, text: "Only http: and https: URLs are allowed." }],
details: { error: "Invalid protocol" },
};
}
} catch {
return {
content: [{ type: "text" as const, text: `Invalid URL: ${url}` }],
details: { error: "Invalid URL" },
};
}
const { config: cfg, error } = ensureWorker();
if (!cfg) {
return {
content: [{ type: "text" as const, text: error! }],
details: { error },
};
}
if (onUpdate) {
onUpdate({
content: [{ type: "text" as const, text: `Running ${action} on ${url}...` }],
details: { action, url, status: "running" },
});
}
let result: WebTestResult;
switch (action) {
case "screenshot":
result = await doScreenshot(cfg, url, ctx.cwd, { width, height, fullPage });
break;
case "content":
result = await doContent(cfg, url, { selector });
break;
case "a11y":
result = await doA11y(cfg, url);
break;
case "responsive":
result = await doResponsive(cfg, url, ctx.cwd, {});
break;
default:
result = { action: action as Action, url, success: false, error: "Unknown action", elapsed: 0 };
}
const output = formatResult(result);
return {
content: [{ type: "text" as const, text: output }],
details: {
action,
url,
status: result.success ? "done" : "error",
screenshots: result.screenshots,
data: result.data,
elapsed: result.elapsed,
},
};
},
renderCall(_params, _theme) {
const p = _params as { action: string; url: string };
const DIM = "\x1b[90m";
const BRIGHT = "\x1b[1;97m";
const RST = "\x1b[0m";
return new Text(`${DIM}web-remote:${RST} ${BRIGHT}${p.action}${RST} ${DIM}${p.url}${RST}`, 0, 0);
},
renderResult(result, _options, _theme) {
const details = result.details as any;
const DIM = "\x1b[90m";
const GREEN = "\x1b[32m";
const RED = "\x1b[91m";
const BRIGHT = "\x1b[1;97m";
const YELLOW = "\x1b[33m";
const RST = "\x1b[0m";
if (details?.status === "error") {
return new Text(`${RED}failed${RST} ${DIM}${details?.action || ""}${RST}`, 0, 0);
}
const elapsed = details?.elapsed ? Math.round(details.elapsed / 1000) : 0;
const action = details?.action || "";
switch (action) {
case "screenshot": {
const count = details?.screenshots?.length ?? 0;
return new Text(
`${GREEN}captured${RST} ${BRIGHT}${count}${RST} ${DIM}screenshot in ${elapsed}s${RST}`,
0, 0,
);
}
case "content": {
const len = details?.data?.textLength ?? 0;
return new Text(
`${GREEN}extracted${RST} ${BRIGHT}${len}${RST} ${DIM}chars in ${elapsed}s${RST}`,
0, 0,
);
}
case "a11y": {
const violations = details?.data?.summary?.violations ?? 0;
const passes = details?.data?.summary?.passes ?? 0;
const color = violations > 0 ? YELLOW : GREEN;
return new Text(
`${color}${violations} violations${RST} ${DIM}${passes} passes in ${elapsed}s${RST}`,
0, 0,
);
}
case "responsive": {
const count = details?.screenshots?.length ?? 0;
return new Text(
`${GREEN}captured${RST} ${BRIGHT}${count}${RST} ${DIM}viewports in ${elapsed}s${RST}`,
0, 0,
);
}
default:
return new Text(`${GREEN}done${RST} ${DIM}in ${elapsed}s${RST}`, 0, 0);
}
},
});
// ── Session start ────────────────────────────
pi.on("session_start", async (_event, ctx) => {
ensureCaptureDir(ctx.cwd);
});
}