706 lines
22 KiB
TypeScript
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);
|
|
});
|
|
}
|