mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
318ffdbdf0
* fix: js statement wrapping + click auto-routes option to selectOption
Bug 1: js command wrapped all code as expressions — const, semicolons,
and multi-line code broke with SyntaxError. Added needsBlockWrapper()
and wrapForEvaluate() helpers (shared with eval) to detect statements
and use block wrapper {…} instead of expression wrapper (…).
Bug 2: clicking <option> refs hung forever because Playwright can't
.click() native select UI. Click handler now checks ARIA role + DOM
tagName and auto-routes to selectOption() via parent <select>.
Bug 3: click timeouts on <option> elements gave no guidance. Now
throws helpful error: "Use browse select instead of click."
* chore: bump version and changelog (v0.4.5)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
/**
|
|
* Read commands — extract data from pages without side effects
|
|
*
|
|
* text, html, links, forms, accessibility, js, eval, css, attrs,
|
|
* console, network, cookies, storage, perf
|
|
*/
|
|
|
|
import type { BrowserManager } from './browser-manager';
|
|
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
|
import type { Page } from 'playwright';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
|
|
function hasAwait(code: string): boolean {
|
|
const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
return /\bawait\b/.test(stripped);
|
|
}
|
|
|
|
/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */
|
|
function needsBlockWrapper(code: string): boolean {
|
|
const trimmed = code.trim();
|
|
if (trimmed.split('\n').length > 1) return true;
|
|
if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(trimmed)) return true;
|
|
if (trimmed.includes(';')) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */
|
|
function wrapForEvaluate(code: string): string {
|
|
if (!hasAwait(code)) return code;
|
|
const trimmed = code.trim();
|
|
return needsBlockWrapper(trimmed)
|
|
? `(async()=>{\n${code}\n})()`
|
|
: `(async()=>(${trimmed}))()`;
|
|
}
|
|
|
|
// Security: Path validation to prevent path traversal attacks
|
|
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
|
|
|
function validateReadPath(filePath: string): void {
|
|
if (path.isAbsolute(filePath)) {
|
|
const resolved = path.resolve(filePath);
|
|
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
|
if (!isSafe) {
|
|
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
|
}
|
|
}
|
|
const normalized = path.normalize(filePath);
|
|
if (normalized.includes('..')) {
|
|
throw new Error('Path traversal sequences (..) are not allowed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract clean text from a page (strips script/style/noscript/svg).
|
|
* Exported for DRY reuse in meta-commands (diff).
|
|
*/
|
|
export async function getCleanText(page: Page): Promise<string> {
|
|
return await page.evaluate(() => {
|
|
const body = document.body;
|
|
if (!body) return '';
|
|
const clone = body.cloneNode(true) as HTMLElement;
|
|
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
|
return clone.innerText
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0)
|
|
.join('\n');
|
|
});
|
|
}
|
|
|
|
export async function handleReadCommand(
|
|
command: string,
|
|
args: string[],
|
|
bm: BrowserManager
|
|
): Promise<string> {
|
|
const page = bm.getPage();
|
|
|
|
switch (command) {
|
|
case 'text': {
|
|
return await getCleanText(page);
|
|
}
|
|
|
|
case 'html': {
|
|
const selector = args[0];
|
|
if (selector) {
|
|
const resolved = await bm.resolveRef(selector);
|
|
if ('locator' in resolved) {
|
|
return await resolved.locator.innerHTML({ timeout: 5000 });
|
|
}
|
|
return await page.innerHTML(resolved.selector);
|
|
}
|
|
return await page.content();
|
|
}
|
|
|
|
case 'links': {
|
|
const links = await page.evaluate(() =>
|
|
[...document.querySelectorAll('a[href]')].map(a => ({
|
|
text: a.textContent?.trim().slice(0, 120) || '',
|
|
href: (a as HTMLAnchorElement).href,
|
|
})).filter(l => l.text && l.href)
|
|
);
|
|
return links.map(l => `${l.text} → ${l.href}`).join('\n');
|
|
}
|
|
|
|
case 'forms': {
|
|
const forms = await page.evaluate(() => {
|
|
return [...document.querySelectorAll('form')].map((form, i) => {
|
|
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
|
const input = el as HTMLInputElement;
|
|
return {
|
|
tag: el.tagName.toLowerCase(),
|
|
type: input.type || undefined,
|
|
name: input.name || undefined,
|
|
id: input.id || undefined,
|
|
placeholder: input.placeholder || undefined,
|
|
required: input.required || undefined,
|
|
value: input.type === 'password' ? '[redacted]' : (input.value || undefined),
|
|
options: el.tagName === 'SELECT'
|
|
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
|
: undefined,
|
|
};
|
|
});
|
|
return {
|
|
index: i,
|
|
action: form.action || undefined,
|
|
method: form.method || 'get',
|
|
id: form.id || undefined,
|
|
fields,
|
|
};
|
|
});
|
|
});
|
|
return JSON.stringify(forms, null, 2);
|
|
}
|
|
|
|
case 'accessibility': {
|
|
const snapshot = await page.locator("body").ariaSnapshot();
|
|
return snapshot;
|
|
}
|
|
|
|
case 'js': {
|
|
const expr = args[0];
|
|
if (!expr) throw new Error('Usage: browse js <expression>');
|
|
const wrapped = wrapForEvaluate(expr);
|
|
const result = await page.evaluate(wrapped);
|
|
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
}
|
|
|
|
case 'eval': {
|
|
const filePath = args[0];
|
|
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
|
validateReadPath(filePath);
|
|
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
const code = fs.readFileSync(filePath, 'utf-8');
|
|
const wrapped = wrapForEvaluate(code);
|
|
const result = await page.evaluate(wrapped);
|
|
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
}
|
|
|
|
case 'css': {
|
|
const [selector, property] = args;
|
|
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
|
const resolved = await bm.resolveRef(selector);
|
|
if ('locator' in resolved) {
|
|
const value = await resolved.locator.evaluate(
|
|
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
|
property
|
|
);
|
|
return value;
|
|
}
|
|
const value = await page.evaluate(
|
|
([sel, prop]) => {
|
|
const el = document.querySelector(sel);
|
|
if (!el) return `Element not found: ${sel}`;
|
|
return getComputedStyle(el).getPropertyValue(prop);
|
|
},
|
|
[resolved.selector, property]
|
|
);
|
|
return value;
|
|
}
|
|
|
|
case 'attrs': {
|
|
const selector = args[0];
|
|
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
|
const resolved = await bm.resolveRef(selector);
|
|
if ('locator' in resolved) {
|
|
const attrs = await resolved.locator.evaluate((el) => {
|
|
const result: Record<string, string> = {};
|
|
for (const attr of el.attributes) {
|
|
result[attr.name] = attr.value;
|
|
}
|
|
return result;
|
|
});
|
|
return JSON.stringify(attrs, null, 2);
|
|
}
|
|
const attrs = await page.evaluate((sel) => {
|
|
const el = document.querySelector(sel);
|
|
if (!el) return `Element not found: ${sel}`;
|
|
const result: Record<string, string> = {};
|
|
for (const attr of el.attributes) {
|
|
result[attr.name] = attr.value;
|
|
}
|
|
return result;
|
|
}, resolved.selector);
|
|
return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
|
|
}
|
|
|
|
case 'console': {
|
|
if (args[0] === '--clear') {
|
|
consoleBuffer.clear();
|
|
return 'Console buffer cleared.';
|
|
}
|
|
const entries = args[0] === '--errors'
|
|
? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning')
|
|
: consoleBuffer.toArray();
|
|
if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)';
|
|
return entries.map(e =>
|
|
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
).join('\n');
|
|
}
|
|
|
|
case 'network': {
|
|
if (args[0] === '--clear') {
|
|
networkBuffer.clear();
|
|
return 'Network buffer cleared.';
|
|
}
|
|
if (networkBuffer.length === 0) return '(no network requests)';
|
|
return networkBuffer.toArray().map(e =>
|
|
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
).join('\n');
|
|
}
|
|
|
|
case 'dialog': {
|
|
if (args[0] === '--clear') {
|
|
dialogBuffer.clear();
|
|
return 'Dialog buffer cleared.';
|
|
}
|
|
if (dialogBuffer.length === 0) return '(no dialogs captured)';
|
|
return dialogBuffer.toArray().map(e =>
|
|
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
|
).join('\n');
|
|
}
|
|
|
|
case 'is': {
|
|
const property = args[0];
|
|
const selector = args[1];
|
|
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
|
|
|
|
const resolved = await bm.resolveRef(selector);
|
|
let locator;
|
|
if ('locator' in resolved) {
|
|
locator = resolved.locator;
|
|
} else {
|
|
locator = page.locator(resolved.selector);
|
|
}
|
|
|
|
switch (property) {
|
|
case 'visible': return String(await locator.isVisible());
|
|
case 'hidden': return String(await locator.isHidden());
|
|
case 'enabled': return String(await locator.isEnabled());
|
|
case 'disabled': return String(await locator.isDisabled());
|
|
case 'checked': return String(await locator.isChecked());
|
|
case 'editable': return String(await locator.isEditable());
|
|
case 'focused': {
|
|
const isFocused = await locator.evaluate(
|
|
(el) => el === document.activeElement
|
|
);
|
|
return String(isFocused);
|
|
}
|
|
default:
|
|
throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
|
|
}
|
|
}
|
|
|
|
case 'cookies': {
|
|
const cookies = await page.context().cookies();
|
|
return JSON.stringify(cookies, null, 2);
|
|
}
|
|
|
|
case 'storage': {
|
|
if (args[0] === 'set' && args[1]) {
|
|
const key = args[1];
|
|
const value = args[2] || '';
|
|
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
|
return `Set localStorage["${key}"]`;
|
|
}
|
|
const storage = await page.evaluate(() => ({
|
|
localStorage: { ...localStorage },
|
|
sessionStorage: { ...sessionStorage },
|
|
}));
|
|
return JSON.stringify(storage, null, 2);
|
|
}
|
|
|
|
case 'perf': {
|
|
const timings = await page.evaluate(() => {
|
|
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
if (!nav) return 'No navigation timing data available.';
|
|
return {
|
|
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
|
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
|
|
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
download: Math.round(nav.responseEnd - nav.responseStart),
|
|
domParse: Math.round(nav.domInteractive - nav.responseEnd),
|
|
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
|
load: Math.round(nav.loadEventEnd - nav.startTime),
|
|
total: Math.round(nav.loadEventEnd - nav.startTime),
|
|
};
|
|
});
|
|
if (typeof timings === 'string') return timings;
|
|
return Object.entries(timings)
|
|
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
|
|
.join('\n');
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown read command: ${command}`);
|
|
}
|
|
}
|