From 46b20fe01ec37602648ebf3a25ab1bf2e483cb9c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 13 Mar 2026 15:43:13 -0700 Subject: [PATCH] refactor: extract command registry to commands.ts, add SNAPSHOT_FLAGS metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NEW: browse/src/commands.ts — command sets + COMMAND_DESCRIPTIONS + load-time validation (zero side effects) - server.ts imports from commands.ts instead of declaring sets inline - snapshot.ts: SNAPSHOT_FLAGS array drives parseSnapshotArgs (metadata-driven, no duplication) - All 186 existing tests pass --- browse/src/commands.ts | 107 +++++++++++++++++++++++++++++++++++++++++ browse/src/server.ts | 25 ++-------- browse/src/snapshot.ts | 75 ++++++++++++++--------------- 3 files changed, 147 insertions(+), 60 deletions(-) create mode 100644 browse/src/commands.ts diff --git a/browse/src/commands.ts b/browse/src/commands.ts new file mode 100644 index 00000000..bdcf9586 --- /dev/null +++ b/browse/src/commands.ts @@ -0,0 +1,107 @@ +/** + * Command registry — single source of truth for all browse commands. + * + * Dependency graph: + * commands.ts ──▶ server.ts (runtime dispatch) + * ──▶ gen-skill-docs.ts (doc generation) + * ──▶ skill-parser.ts (validation) + * ──▶ skill-check.ts (health reporting) + * + * Zero side effects. Safe to import from build scripts and tests. + */ + +export const READ_COMMANDS = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', + 'js', 'eval', 'css', 'attrs', + 'console', 'network', 'cookies', 'storage', 'perf', + 'dialog', 'is', +]); + +export const WRITE_COMMANDS = new Set([ + 'goto', 'back', 'forward', 'reload', + 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', + 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', + 'upload', 'dialog-accept', 'dialog-dismiss', +]); + +export const META_COMMANDS = new Set([ + 'tabs', 'tab', 'newtab', 'closetab', + 'status', 'stop', 'restart', + 'screenshot', 'pdf', 'responsive', + 'chain', 'diff', + 'url', 'snapshot', +]); + +export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); + +export const COMMAND_DESCRIPTIONS: Record = { + // Navigation + 'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto ' }, + 'back': { category: 'Navigation', description: 'History back' }, + 'forward': { category: 'Navigation', description: 'History forward' }, + 'reload': { category: 'Navigation', description: 'Reload page' }, + 'url': { category: 'Navigation', description: 'Print current URL' }, + // Reading + 'text': { category: 'Reading', description: 'Cleaned page text' }, + 'html': { category: 'Reading', description: 'innerHTML', usage: 'html [selector]' }, + 'links': { category: 'Reading', description: 'All links as "text → href"' }, + 'forms': { category: 'Reading', description: 'Form fields as JSON' }, + 'accessibility': { category: 'Reading', description: 'Full ARIA tree' }, + // Inspection + 'js': { category: 'Inspection', description: 'Run JavaScript', usage: 'js ' }, + 'eval': { category: 'Inspection', description: 'Run JS file', usage: 'eval ' }, + 'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css ' }, + 'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs ' }, + 'is': { category: 'Inspection', description: 'State check', usage: 'is ' }, + 'console': { category: 'Inspection', description: 'Console messages', usage: 'console [--clear|--errors]' }, + 'network': { category: 'Inspection', description: 'Network requests', usage: 'network [--clear]' }, + 'dialog': { category: 'Inspection', description: 'Dialog messages', usage: 'dialog [--clear]' }, + 'cookies': { category: 'Inspection', description: 'All cookies as JSON' }, + 'storage': { category: 'Inspection', description: 'localStorage + sessionStorage', usage: 'storage [set k v]' }, + 'perf': { category: 'Inspection', description: 'Page load timings' }, + // Interaction + 'click': { category: 'Interaction', description: 'Click element', usage: 'click ' }, + 'fill': { category: 'Interaction', description: 'Fill input', usage: 'fill ' }, + 'select': { category: 'Interaction', description: 'Select dropdown option', usage: 'select ' }, + 'hover': { category: 'Interaction', description: 'Hover element', usage: 'hover ' }, + 'type': { category: 'Interaction', description: 'Type into focused element', usage: 'type ' }, + 'press': { category: 'Interaction', description: 'Press key', usage: 'press ' }, + 'scroll': { category: 'Interaction', description: 'Scroll element into view', usage: 'scroll [sel]' }, + 'wait': { category: 'Interaction', description: 'Wait for element/condition', usage: 'wait ' }, + 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload ' }, + 'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport ' }, + 'cookie': { category: 'Interaction', description: 'Set cookie' }, + 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, + 'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from real browser', usage: 'cookie-import-browser [browser] [--domain d]' }, + 'header': { category: 'Interaction', description: 'Set custom request header', usage: 'header ' }, + 'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent ' }, + 'dialog-accept': { category: 'Interaction', description: 'Auto-accept next dialog', usage: 'dialog-accept [text]' }, + 'dialog-dismiss': { category: 'Interaction', description: 'Auto-dismiss next dialog' }, + // Visual + 'screenshot': { category: 'Visual', description: 'Save screenshot', usage: 'screenshot [path]' }, + 'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' }, + 'responsive': { category: 'Visual', description: 'Mobile/tablet/desktop screenshots', usage: 'responsive [prefix]' }, + 'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff ' }, + // Tabs + 'tabs': { category: 'Tabs', description: 'List open tabs' }, + 'tab': { category: 'Tabs', description: 'Switch to tab', usage: 'tab ' }, + 'newtab': { category: 'Tabs', description: 'Open new tab', usage: 'newtab [url]' }, + 'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' }, + // Server + 'status': { category: 'Server', description: 'Health check' }, + 'stop': { category: 'Server', description: 'Shutdown server' }, + 'restart': { category: 'Server', description: 'Restart server' }, + // Meta + 'snapshot':{ category: 'Snapshot', description: 'Accessibility tree with @refs', usage: 'snapshot [flags]' }, + 'chain': { category: 'Meta', description: 'Multi-command from JSON stdin' }, +}; + +// Load-time validation: descriptions must cover exactly the command sets +const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]); +const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS)); +for (const cmd of allCmds) { + if (!descKeys.has(cmd)) throw new Error(`COMMAND_DESCRIPTIONS missing entry for: ${cmd}`); +} +for (const key of descKeys) { + if (!allCmds.has(key)) throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`); +} diff --git a/browse/src/server.ts b/browse/src/server.ts index 0825b176..466cb0fc 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -105,28 +105,9 @@ const idleCheckInterval = setInterval(() => { } }, 60_000); -// ─── Command Sets (exported for chain command) ────────────────── -export const READ_COMMANDS = new Set([ - 'text', 'html', 'links', 'forms', 'accessibility', - 'js', 'eval', 'css', 'attrs', - 'console', 'network', 'cookies', 'storage', 'perf', - 'dialog', 'is', -]); - -export const WRITE_COMMANDS = new Set([ - 'goto', 'back', 'forward', 'reload', - 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', - 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', - 'upload', 'dialog-accept', 'dialog-dismiss', -]); - -export const META_COMMANDS = new Set([ - 'tabs', 'tab', 'newtab', 'closetab', - 'status', 'stop', 'restart', - 'screenshot', 'pdf', 'responsive', - 'chain', 'diff', - 'url', 'snapshot', -]); +// ─── Command Sets (from commands.ts — single source of truth) ─── +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index b0c7b80f..9fb63d47 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -40,6 +40,30 @@ interface SnapshotOptions { cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc. } +/** + * Snapshot flag metadata — single source of truth for CLI parsing and doc generation. + * + * Imported by: + * - gen-skill-docs.ts (generates {{SNAPSHOT_FLAGS}} tables) + * - skill-parser.ts (validates flags in SKILL.md examples) + */ +export const SNAPSHOT_FLAGS: Array<{ + short: string; + long: string; + description: string; + takesValue?: boolean; + optionKey: keyof SnapshotOptions; +}> = [ + { short: '-i', long: '--interactive', description: 'Interactive elements only', optionKey: 'interactive' }, + { short: '-c', long: '--compact', description: 'Remove empty structural elements', optionKey: 'compact' }, + { short: '-d', long: '--depth', description: 'Limit tree depth', takesValue: true, optionKey: 'depth' }, + { short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, optionKey: 'selector' }, + { short: '-D', long: '--diff', description: 'Diff against previous snapshot', optionKey: 'diff' }, + { short: '-a', long: '--annotate', description: 'Annotated screenshot with ref labels', optionKey: 'annotate' }, + { short: '-o', long: '--output', description: 'Output path for annotated screenshot', takesValue: true, optionKey: 'outputPath' }, + { short: '-C', long: '--cursor-interactive', description: 'Scan cursor:pointer/onclick/tabindex elements', optionKey: 'cursorInteractive' }, +]; + interface ParsedNode { indent: number; role: string; @@ -50,49 +74,24 @@ interface ParsedNode { } /** - * Parse CLI args into SnapshotOptions + * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata. */ export function parseSnapshotArgs(args: string[]): SnapshotOptions { const opts: SnapshotOptions = {}; for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '-i': - case '--interactive': - opts.interactive = true; - break; - case '-c': - case '--compact': - opts.compact = true; - break; - case '-d': - case '--depth': - opts.depth = parseInt(args[++i], 10); + const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]); + if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`); + if (flag.takesValue) { + const value = args[++i]; + if (!value) throw new Error(`Usage: snapshot ${flag.short} `); + if (flag.optionKey === 'depth') { + (opts as any)[flag.optionKey] = parseInt(value, 10); if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d '); - break; - case '-s': - case '--selector': - opts.selector = args[++i]; - if (!opts.selector) throw new Error('Usage: snapshot -s '); - break; - case '-D': - case '--diff': - opts.diff = true; - break; - case '-a': - case '--annotate': - opts.annotate = true; - break; - case '-o': - case '--output': - opts.outputPath = args[++i]; - if (!opts.outputPath) throw new Error('Usage: snapshot -o '); - break; - case '-C': - case '--cursor-interactive': - opts.cursorInteractive = true; - break; - default: - throw new Error(`Unknown snapshot flag: ${args[i]}`); + } else { + (opts as any)[flag.optionKey] = value; + } + } else { + (opts as any)[flag.optionKey] = true; } } return opts;