Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:05:10 +08:00
commit 1c87823db1
17 changed files with 1920 additions and 0 deletions

91
skills/scripts/click.ts Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Element interaction for testing UI behavior
* Usage: node click.js --selector ".button" [--url https://example.com] [--wait-for ".result"]
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError, waitForElement, getElementInfo } from '../lib/browser.js';
interface ClickArgs {
selector?: string;
url?: string;
'wait-for'?: string;
'wait-timeout'?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
async function click() {
const args = parseArgs(process.argv.slice(2)) as ClickArgs;
if (!args.selector) {
return outputError(new Error('--selector is required'));
return;
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
});
// Navigate to URL if provided
if (args.url) {
await session.page.goto(args.url, { waitUntil: 'networkidle' });
}
// Wait for element to be available
const waitTimeout = args['wait-timeout'] ? parseInt(args['wait-timeout']) : 30000;
await waitForElement(session.page, args.selector, waitTimeout);
// Get element info before click
const elementInfo = await getElementInfo(session.page, args.selector);
// Check if element is visible and clickable
if (!elementInfo.visible) {
return outputError(new Error(`Element is not visible: ${args.selector}`));
return;
}
// Click the element
await session.page.click(args.selector);
// Wait for navigation or new content if specified
if (args['wait-for']) {
try {
await waitForElement(session.page, args['wait-for'], waitTimeout);
} catch (error) {
// Don't fail if wait-for element doesn't appear, just log it
console.warn(`Warning: Wait-for element not found: ${args['wait-for']}`);
}
}
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
action: {
type: 'click',
selector: args.selector,
elementInfo: elementInfo,
waitFor: args['wait-for'] || null,
},
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(result);
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
click();

146
skills/scripts/console.ts Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Console log monitoring and error tracking
* Usage: node console.js [--url URL] [--types error,warn] [--duration 5000] [--no-navigation]
*
* Options:
* --url URL to navigate to (required if no existing browser session)
* --types Console message types to capture (comma-separated, default: all)
* --duration How long to monitor console (ms, default: 5000)
* --no-navigation Don't navigate, use existing browser session
*
* Examples:
* node console.js --url https://your-app.com --types error,warn
* node console.js --no-navigation true # Uses existing session
* node navigate.js --url https://your-app.com --close false
* node console.js --no-navigation true --types error # Monitor existing page
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError, getExistingSession } from '../lib/browser.js';
interface ConsoleArgs {
url?: string;
types?: string;
duration?: string;
timeout?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
'no-navigation'?: string | boolean;
}
interface ConsoleMessage {
type: string;
text: string;
location?: {
url: string;
lineNumber: number;
columnNumber: number;
};
timestamp: number;
}
async function monitorConsole() {
const args = parseArgs(process.argv.slice(2)) as ConsoleArgs;
// Smart URL handling
let shouldNavigate = args['no-navigation'] !== 'true';
let targetUrl = args.url;
// If no URL provided, try to use existing session
if (!targetUrl) {
const existingSession = await getExistingSession();
if (existingSession && existingSession.browser.isConnected()) {
// Use existing session, don't navigate
shouldNavigate = false;
targetUrl = existingSession.page.url();
} else {
// No existing session and no URL provided - require explicit URL from user
return outputError(new Error('No browser session found. Please provide a URL with --url or navigate first using navigate.js. For example: --url https://your-site.com'));
}
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
const messages: ConsoleMessage[] = [];
const filterTypes = args.types ? args.types.split(',').map(t => t.trim().toLowerCase()) : null;
const duration = args.duration ? parseInt(args.duration) : 5000;
// Set up console message listener
session.page.on('console', (msg) => {
const messageType = msg.type().toLowerCase();
// Filter by message types if specified
if (filterTypes && !filterTypes.includes(messageType)) {
return;
}
const consoleMessage: ConsoleMessage = {
type: messageType,
text: msg.text(),
timestamp: Date.now(),
};
// Add location info if available
const location = msg.location();
if (location) {
consoleMessage.location = {
url: location.url,
lineNumber: location.lineNumber,
columnNumber: location.columnNumber,
};
}
messages.push(consoleMessage);
});
// Navigate to the URL if needed
if (shouldNavigate && targetUrl) {
await session.page.goto(targetUrl, { waitUntil: 'networkidle' });
}
// Wait for the specified duration
await new Promise(resolve => setTimeout(resolve, duration));
// Count messages by type
const messageCount = messages.reduce((acc, msg) => {
acc[msg.type] = (acc[msg.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
monitoring: {
duration: duration,
types: filterTypes || 'all',
messageCount: messages.length,
messageCountByType: messageCount,
usedExistingSession: !args.url && !shouldNavigate,
autoNavigated: shouldNavigate && args.url,
},
messages: messages,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(result);
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
monitorConsole();

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Execute JavaScript for debugging and analysis
* Usage: node evaluate.js --url https://example.com --script "document.title"
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError, executeScript } from '../lib/browser.js';
interface EvaluateArgs {
url?: string;
script?: string;
timeout?: string;
'wait-until'?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
async function evaluate() {
const args = parseArgs(process.argv.slice(2)) as EvaluateArgs;
if (!args.script) {
return outputError(new Error('--script is required'));
return;
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
// Navigate to URL if provided
if (args.url) {
await session.page.goto(args.url, {
waitUntil: args['wait-until'] as any || 'load',
timeout: args.timeout ? parseInt(args.timeout) : 30000,
});
}
// Execute the script
const result = await executeScript(session.page, args.script);
const responseData = {
success: true,
url: session.page.url(),
title: await session.page.title(),
script: args.script,
result: result,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(responseData);
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
evaluate();

104
skills/scripts/fill.ts Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Form testing and input validation
* Usage: node fill.js --selector "#input" --value "text" [--url https://example.com] [--clear true]
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError, waitForElement, getElementInfo } from '../lib/browser.js';
interface FillArgs {
selector?: string;
value?: string;
url?: string;
clear?: string | boolean;
'wait-timeout'?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
async function fill() {
const args = parseArgs(process.argv.slice(2)) as FillArgs;
if (!args.selector) {
return outputError(new Error('--selector is required'));
return;
}
if (args.value === undefined) {
return outputError(new Error('--value is required'));
return;
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
});
// Navigate to URL if provided
if (args.url) {
await session.page.goto(args.url, { waitUntil: 'networkidle' });
}
// Wait for element to be available
const waitTimeout = args['wait-timeout'] ? parseInt(args['wait-timeout']) : 30000;
await waitForElement(session.page, args.selector, waitTimeout);
// Get element info before filling
const elementInfo = await getElementInfo(session.page, args.selector);
// Check if element is visible and fillable
if (!elementInfo.visible) {
return outputError(new Error(`Element is not visible: ${args.selector}`));
return;
}
// Check if element is an input-like element
const fillableTags = ['input', 'textarea', 'select'];
if (!fillableTags.includes(elementInfo.tagName)) {
return outputError(new Error(`Element is not fillable: ${elementInfo.tagName} (${args.selector})`));
return;
}
// Clear the field if requested
if (args.clear === 'true') {
await session.page.fill(args.selector, '');
}
// Fill the element with the value
await session.page.fill(args.selector, args.value);
// Verify the value was set
const actualValue = await session.page.inputValue(args.selector);
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
action: {
type: 'fill',
selector: args.selector,
elementInfo: elementInfo,
value: args.value,
actualValue: actualValue,
cleared: args.clear === 'true',
},
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(result);
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
fill();

155
skills/scripts/install.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
# Installation script for browser-devtools skill
set -e
echo "🚀 Installing browser-devtools skill..."
# Check if Node.js is installed
if ! command -v node &>/dev/null; then
echo "❌ Node.js is not installed. Please install Node.js 18+ first."
exit 1
fi
# Check Node.js version
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo "❌ Node.js 18+ is required. Current version: $(node -v)"
exit 1
fi
echo "✅ Node.js $(node -v) detected"
# Check if npm dependencies are already installed
if [ ! -d "node_modules" ] || ! npm list playwright &>/dev/null; then
echo "📦 Installing npm dependencies..."
npm install
else
echo "✅ npm dependencies already installed"
fi
# Check if build is needed
BUILD_NEEDED=false
if [ ! -d "dist" ]; then
BUILD_NEEDED=true
else
# Check if any source files are newer than dist files
for ts_file in scripts/*.ts lib/*.ts; do
if [ -f "$ts_file" ]; then
js_file="dist/${ts_file%.*}.js"
if [ ! -f "$js_file" ] || [ "$ts_file" -nt "$js_file" ]; then
BUILD_NEEDED=true
break
fi
fi
done
fi
if [ "$BUILD_NEEDED" = true ]; then
echo "📦 Building TypeScript files..."
npm run build
else
echo "✅ Build files are up to date"
fi
# Check if Playwright browsers are installed
BROWSER_CHECK=$(node -e "try { require('playwright').chromium.executablePath(); console.log('OK'); } catch(e) { console.log('MISSING'); }" 2>/dev/null || echo "MISSING")
if [ "$BROWSER_CHECK" != "OK" ]; then
echo "🌐 Installing Playwright browsers..."
npx playwright install
else
echo "✅ Playwright browsers already installed"
fi
# Install system dependencies on Linux
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
echo "🔧 Installing system dependencies for Linux..."
# Detect distribution
if [ -f /etc/debian_version ]; then
# Debian/Ubuntu
echo "Detected Debian/Ubuntu-based system"
sudo apt-get update
sudo apt-get install -y \
libnss3 \
libnspr4 \
libasound2t64 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1
elif [ -f /etc/redhat-release ]; then
# RHEL/Fedora/CentOS
echo "Detected RedHat/Fedora-based system"
if command -v dnf &>/dev/null; then
sudo dnf install -y \
nss \
nspr \
alsa-lib \
atk \
at-spi2-atk \
cups-libs \
libdrm \
libxkbcommon \
libXcomposite \
libXdamage \
libXfixes \
libXrandr \
mesa-libgbm
else
sudo yum install -y \
nss \
nspr \
alsa-lib \
atk \
at-spi2-atk \
cups-libs \
libdrm \
libxkbcommon \
libXcomposite \
libXdamage \
libXfixes \
libXrandr \
mesa-libgbm
fi
elif [ -f /etc/arch-release ]; then
# Arch Linux
echo "Detected Arch-based system"
sudo pacman -S --needed \
nss \
nspr \
alsa-lib \
atk \
at-spi2-atk \
cups \
libdrm \
libxkbcommon \
libxcomposite \
libxdamage \
libxfixes \
libxrandr \
mesa
else
echo "⚠️ Unknown Linux distribution. Please install browser dependencies manually."
echo "See: https://playwright.dev/docs/linux/"
fi
fi
# Create necessary directories
mkdir -p docs/screenshots
mkdir -p docs/traces
mkdir -p docs/reports
echo "✅ Installation completed successfully!"
echo ""
echo "🧪 Test the installation:"
echo " node dist/scripts/navigate.js --url https://example.com"
echo ""
echo "📚 For usage examples, see SKILL.md"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Navigate to a URL for debugging
* Usage: node navigate.js --url https://example.com [--wait-until networkidle2] [--timeout 30000]
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError } from '../lib/browser.js';
interface NavigateArgs {
url?: string;
'wait-until'?: string;
timeout?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
async function navigate() {
const args = parseArgs(process.argv.slice(2)) as NavigateArgs;
if (!args.url) {
return outputError(new Error('--url is required'));
return;
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
const waitUntil = args['wait-until'] as any || 'networkidle';
await session.page.goto(args.url, {
waitUntil,
timeout: args.timeout ? parseInt(args.timeout) : 30000,
});
const title = await session.page.title();
const finalUrl = session.page.url();
const result = {
success: true,
url: finalUrl,
title: title,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(result);
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
navigate();

254
skills/scripts/network.ts Normal file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
/**
* Network request debugging and performance analysis
* Usage: node network.js --url https://example.com [--types xhr,fetch] [--duration 5000] [--output requests.json]
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError } from '../lib/browser.js';
import { writeFile } from 'fs/promises';
interface NetworkArgs {
url?: string;
types?: string;
duration?: string;
output?: string;
timeout?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
interface NetworkRequest {
url: string;
method: string;
status: number;
statusText: string;
type: string;
resourceType: string;
size: {
request: number;
response: number;
total: number;
};
timing: {
startTime: number;
endTime: number;
duration: number;
};
headers: {
request: Record<string, string>;
response: Record<string, string>;
};
failed: boolean;
errorText?: string;
}
async function monitorNetwork() {
const args = parseArgs(process.argv.slice(2)) as NetworkArgs;
if (!args.url) {
return outputError(new Error('--url is required'));
return;
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
const requests: NetworkRequest[] = [];
const filterTypes = args.types ? args.types.split(',').map(t => t.trim().toLowerCase()) : null;
const duration = args.duration ? parseInt(args.duration) : 5000;
// Set up request and response listeners
session.page.on('request', (request) => {
const resourceType = request.resourceType().toLowerCase();
// Filter by resource types if specified
if (filterTypes && !filterTypes.includes(resourceType)) {
return;
}
const requestData: any = {
url: request.url(),
method: request.method(),
resourceType: resourceType,
type: 'request',
headers: request.headers(),
size: {
request: 0, // Playwright doesn't expose header size directly
response: 0,
total: 0,
},
timing: {
startTime: Date.now(),
endTime: 0,
duration: 0,
},
status: 0,
statusText: '',
failed: false,
};
// Store temporary request data
(request as any)._requestData = requestData;
});
session.page.on('response', async (response) => {
const request = response.request() as any;
const requestData = request._requestData;
if (!requestData) {
return; // Skip if not tracked
}
const resourceType = response.request().resourceType().toLowerCase();
// Filter by resource types if specified
if (filterTypes && !filterTypes.includes(resourceType)) {
return;
}
const endTime = Date.now();
const responseBody = await response.text();
const responseSize = responseBody.length;
const networkRequest: NetworkRequest = {
url: response.url(),
method: request.method(),
status: response.status(),
statusText: response.statusText(),
type: 'response',
resourceType: resourceType,
size: {
request: requestData.size.request,
response: responseSize,
total: requestData.size.request + responseSize,
},
timing: {
startTime: requestData.timing.startTime,
endTime: endTime,
duration: endTime - requestData.timing.startTime,
},
headers: {
request: requestData.headers,
response: response.headers(),
},
failed: !response.ok(),
errorText: !response.ok() ? response.statusText() : undefined,
};
requests.push(networkRequest);
});
session.page.on('requestfailed', (request) => {
const requestData = (request as any)._requestData;
if (!requestData) {
return; // Skip if not tracked
}
const resourceType = request.resourceType().toLowerCase();
// Filter by resource types if specified
if (filterTypes && !filterTypes.includes(resourceType)) {
return;
}
const endTime = Date.now();
const networkRequest: NetworkRequest = {
url: request.url(),
method: request.method(),
status: 0,
statusText: 'Failed',
type: 'failed',
resourceType: resourceType,
size: requestData.size,
timing: {
startTime: requestData.timing.startTime,
endTime: endTime,
duration: endTime - requestData.timing.startTime,
},
headers: {
request: requestData.headers,
response: {},
},
failed: true,
errorText: (request as any).failure()?.errorText || 'Request failed',
};
requests.push(networkRequest);
});
// Navigate to the URL
await session.page.goto(args.url, { waitUntil: 'networkidle' });
// Wait for the specified duration
await new Promise(resolve => setTimeout(resolve, duration));
// Calculate summary statistics
const summary = {
totalRequests: requests.length,
successfulRequests: requests.filter(r => !r.failed).length,
failedRequests: requests.filter(r => r.failed).length,
totalSize: requests.reduce((sum, r) => sum + r.size.total, 0),
averageResponseTime: requests.length > 0
? requests.reduce((sum, r) => sum + r.timing.duration, 0) / requests.length
: 0,
requestsByType: requests.reduce((acc, r) => {
acc[r.resourceType] = (acc[r.resourceType] || 0) + 1;
return acc;
}, {} as Record<string, number>),
requestsByStatus: requests.reduce((acc, r) => {
if (r.failed) {
acc['failed'] = (acc['failed'] || 0) + 1;
} else {
const statusGroup = Math.floor(r.status / 100) * 100;
acc[statusGroup] = (acc[statusGroup] || 0) + 1;
}
return acc;
}, {} as Record<string, number>),
};
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
monitoring: {
duration: duration,
types: filterTypes || 'all',
...summary,
},
requests: requests,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
// Save to file if output path provided
if (args.output) {
await writeFile(args.output, JSON.stringify(result, null, 2));
outputJSON({
success: true,
output: args.output,
...summary,
url: session.page.url()
});
} else {
outputJSON(result);
}
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
monitorNetwork();

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* Performance metrics and Core Web Vitals
* Usage: node performance.js --url https://example.com [--trace trace.json] [--resources true]
*/
import {
getBrowserSession,
closeBrowserSession,
parseArgs,
outputJSON,
outputError,
} from "../lib/browser.js";
import { writeFile } from "fs/promises";
interface PerformanceArgs {
url?: string;
trace?: string;
resources?: string | boolean;
timeout?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
interface CoreWebVitals {
LCP: number | null; // Largest Contentful Paint
FID: number | null; // First Input Delay
CLS: number; // Cumulative Layout Shift
FCP: number | null; // First Contentful Paint
TTFB: number | null; // Time to First Byte
}
interface PerformanceResource {
name: string;
type: string;
duration: number;
size: number;
startTime: number;
responseEnd: number;
}
async function measurePerformance() {
const args = parseArgs(process.argv.slice(2)) as PerformanceArgs;
if (!args.url) {
return outputError(new Error("--url is required"));
}
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== "false",
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
// Start tracing if requested
if (args.trace) {
await session.page.context().tracing.start({
screenshots: true,
snapshots: true,
sources: true,
});
}
// Navigate to the URL
await session.page.goto(args.url, { waitUntil: "networkidle" });
// Stop tracing and save if requested
if (args.trace) {
await session.page.context().tracing.stop({ path: args.trace });
}
// Get performance metrics from Chrome DevTools Protocol
const client = await session.page.context().newCDPSession(session.page);
const metrics = await client.send("Performance.getMetrics");
// Get Core Web Vitals
const vitals = await session.page.evaluate(() => {
return new Promise((resolve) => {
const vitals: CoreWebVitals = {
LCP: null,
FID: null,
CLS: 0,
FCP: null,
TTFB: null,
};
// LCP - Largest Contentful Paint
try {
new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1] as any;
vitals.LCP = lastEntry.renderTime || lastEntry.loadTime;
}
}).observe({
entryTypes: ["largest-contentful-paint"],
buffered: true,
});
} catch (e) {
// LCP not supported
}
// CLS - Cumulative Layout Shift
try {
new PerformanceObserver((list) => {
list.getEntries().forEach((entry: any) => {
if (!entry.hadRecentInput) {
vitals.CLS += entry.value;
}
});
}).observe({ entryTypes: ["layout-shift"], buffered: true });
} catch (e) {
// CLS not supported
}
// FCP - First Contentful Paint
try {
const paintEntries = performance.getEntriesByType("paint");
const fcpEntry = paintEntries.find(
(e: any) => e.name === "first-contentful-paint",
);
if (fcpEntry) {
vitals.FCP = fcpEntry.startTime;
}
} catch (e) {
// FCP not supported
}
// TTFB - Time to First Byte
try {
const [navigationEntry] = performance.getEntriesByType(
"navigation",
) as any[];
if (navigationEntry) {
vitals.TTFB =
navigationEntry.responseStart - navigationEntry.requestStart;
}
} catch (e) {
// Navigation timing not supported
}
// Wait a bit for metrics to stabilize
setTimeout(() => resolve(vitals), 1000);
});
});
// Get resource timing information
let resources: PerformanceResource[] = [];
if (args.resources === "true") {
resources = await session.page.evaluate(() => {
return performance.getEntriesByType("resource").map((r: any) => ({
name: r.name,
type: r.initiatorType,
duration: r.duration,
size: r.transferSize || 0,
startTime: r.startTime,
responseEnd: r.responseEnd,
}));
});
}
const resourceSummary = {
count: resources.length,
totalDuration: resources.reduce((sum, r) => sum + r.duration, 0),
totalSize: resources.reduce((sum, r) => sum + r.size, 0),
averageDuration:
resources.length > 0
? resources.reduce((sum, r) => sum + r.duration, 0) / resources.length
: 0,
resourcesByType: resources.reduce(
(acc, r) => {
acc[r.type] = (acc[r.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
),
};
// Process metrics into a more usable format
const processedMetrics: Record<string, any> = {};
metrics.metrics?.forEach((metric: any) => {
processedMetrics[metric.name] = metric.value;
});
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
metrics: {
...processedMetrics,
JSHeapUsedSizeMB: (
(processedMetrics.JSHeapUsedSize || 0) /
1024 /
1024
).toFixed(2),
JSHeapTotalSizeMB: (
(processedMetrics.JSHeapTotalSize || 0) /
1024 /
1024
).toFixed(2),
},
vitals: vitals,
resources:
args.resources === "true"
? {
...resourceSummary,
items: resources,
}
: resourceSummary,
trace: args.trace || null,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(result);
if (args.close !== "false") {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
measurePerformance();

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Capture screenshots for visual debugging
* Usage: node screenshot.js --url https://example.com --output screenshot.png [--full-page true] [--selector .element]
*/
import {
getBrowserSession,
closeBrowserSession,
parseArgs,
outputJSON,
outputError,
createScreenshot,
getOutputDirectory,
} from "../lib/browser.js";
import { writeFile } from "fs/promises";
import { join } from "path";
interface ScreenshotArgs {
url?: string;
output?: string;
"full-page"?: string | boolean;
selector?: string;
format?: string;
quality?: string;
"wait-until"?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
async function takeScreenshot() {
const args = parseArgs(process.argv.slice(2)) as ScreenshotArgs;
if (!args.url && !args.selector) {
return outputError(new Error("Either --url or --selector is required"));
}
// --output is optional - will auto-generate if not provided
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== "false",
});
// Navigate to URL if provided
if (args.url) {
await session.page.goto(args.url, {
waitUntil: (args["wait-until"] as any) || "load",
});
}
const outputPath =
args.output || join(getOutputDirectory(), `screenshot_${Date.now()}.png`);
const fullPage = args["full-page"] === "true";
// Create screenshot
const result = await createScreenshot(session.page, outputPath, {
fullPage,
selector: args.selector,
});
const screenshotData = {
success: true,
url: session.page.url(),
title: await session.page.title(),
outputPath: typeof result === "string" ? result : outputPath,
fullPage,
selector: args.selector || null,
size: typeof result === "string" ? null : result.length,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
outputJSON(screenshotData);
if (args.close !== "false") {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
takeScreenshot();

172
skills/scripts/snapshot.ts Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
/**
* DOM inspection and element discovery
* Usage: node snapshot.js --url https://example.com [--output snapshot.json]
*/
import { getBrowserSession, closeBrowserSession, parseArgs, outputJSON, outputError, getOutputDirectory } from '../lib/browser.js';
import { writeFile } from 'fs/promises';
import { join } from 'path';
interface SnapshotArgs {
url?: string;
output?: string;
timeout?: string;
headless?: string | boolean;
close?: string | boolean;
browser?: string;
}
interface ElementInfo {
index: number;
tagName: string;
id: string | null;
className: string | null;
name: string | null;
value: string | null;
type: string | null;
text: string | null;
href: string | null;
selector: string;
xpath: string;
visible: boolean;
position: {
x: number;
y: number;
width: number;
height: number;
};
}
async function takeSnapshot() {
const args = parseArgs(process.argv.slice(2)) as SnapshotArgs;
try {
const session = await getBrowserSession({
browser: args.browser as any,
headless: args.headless !== 'false',
timeout: args.timeout ? parseInt(args.timeout) : undefined,
});
// Navigate if URL provided
if (args.url) {
await session.page.goto(args.url, {
waitUntil: 'networkidle',
timeout: args.timeout ? parseInt(args.timeout) : 30000,
});
}
// Get interactive elements with metadata
const elements = await session.page.evaluate(() => {
const interactiveSelectors = [
'a[href]',
'button',
'input',
'textarea',
'select',
'[onclick]',
'[role="button"]',
'[role="link"]',
'[contenteditable]',
'[tabindex]'
];
const elements: ElementInfo[] = [];
const selector = interactiveSelectors.join(', ');
const nodes = document.querySelectorAll(selector);
nodes.forEach((el, index) => {
const rect = el.getBoundingClientRect();
const element = el as HTMLElement;
// Generate unique selector
let uniqueSelector = '';
if (element.id) {
uniqueSelector = `#${element.id}`;
} else if (element.className) {
const classes = Array.from(element.classList).join('.');
uniqueSelector = `${element.tagName.toLowerCase()}.${classes}`;
} else {
uniqueSelector = element.tagName.toLowerCase();
}
elements.push({
index: index,
tagName: element.tagName.toLowerCase(),
id: element.id || null,
className: element.className || null,
name: (element as any).name || null,
value: (element as any).value || null,
type: (element as any).type || null,
text: element.textContent?.trim().substring(0, 100) || null,
href: (element as HTMLAnchorElement).href || null,
selector: uniqueSelector,
xpath: getXPath(element),
visible: rect.width > 0 && rect.height > 0,
position: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
});
});
function getXPath(element: Element): string {
if (element.id) {
return `//*[@id="${element.id}"]`;
}
if (element === document.body) {
return '/html/body';
}
let ix = 0;
const parent = element.parentNode as Element;
const siblings = parent?.children || [];
for (let i = 0; i < siblings.length; i++) {
const sibling = siblings[i];
if (sibling === element) {
return getXPath(parent) + '/' + element.tagName.toLowerCase() + '[' + (ix + 1) + ']';
}
if (sibling.tagName === element.tagName) {
ix++;
}
}
return '';
}
return elements;
});
const result = {
success: true,
url: session.page.url(),
title: await session.page.title(),
elementCount: elements.length,
elements: elements,
sessionId: session.sessionId,
timestamp: new Date().toISOString(),
};
// Save to file if output path provided, otherwise save to project directory
const outputPath = args.output || join(getOutputDirectory(), `snapshot_${Date.now()}.json`);
await writeFile(outputPath, JSON.stringify(result, null, 2));
outputJSON({
success: true,
output: outputPath,
elementCount: elements.length,
url: session.page.url()
});
if (args.close !== 'false') {
await closeBrowserSession();
} else {
// Explicitly exit the process when keeping session open
process.exit(0);
}
} catch (error) {
return outputError(error instanceof Error ? error : new Error(String(error)));
}
}
takeSnapshot();