diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 2fd0b421..fff5c9a6 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -21,6 +21,7 @@ export const READ_COMMANDS = new Set([ export const WRITE_COMMANDS = new Set([ 'goto', 'back', 'forward', 'reload', + 'load-html', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', @@ -64,7 +65,8 @@ export function wrapUntrustedContent(result: string, url: string): string { export const COMMAND_DESCRIPTIONS: Record = { // Navigation - 'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto ' }, + 'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto ' }, + 'load-html': { category: 'Navigation', description: 'Load a local HTML file via setContent (no HTTP server needed). For self-contained HTML (inline CSS/JS, data URIs). For HTML on disk, goto file://... is often cleaner.', usage: 'load-html [--wait-until load|domcontentloaded|networkidle]' }, 'back': { category: 'Navigation', description: 'History back' }, 'forward': { category: 'Navigation', description: 'History forward' }, 'reload': { category: 'Navigation', description: 'Reload page' }, @@ -99,7 +101,7 @@ export const COMMAND_DESCRIPTIONS: Record' }, 'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload [file2...]' }, - 'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport ' }, + 'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [] [--scale ]' }, 'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie =' }, 'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import ' }, 'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' }, @@ -112,7 +114,7 @@ export const COMMAND_DESCRIPTIONS: Record [--selector sel] [--dir path] [--limit N]' }, 'archive': { category: 'Extraction', description: 'Save complete page as MHTML via CDP', usage: 'archive [path]' }, // Visual - 'screenshot': { category: 'Visual', description: 'Save screenshot (supports element crop via CSS/@ref, --clip region, --viewport)', usage: 'screenshot [--viewport] [--clip x,y,w,h] [selector|@ref] [path]' }, + 'screenshot': { category: 'Visual', description: 'Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work.', usage: 'screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]' }, 'pdf': { category: 'Visual', description: 'Save as PDF', usage: 'pdf [path]' }, 'responsive': { category: 'Visual', description: 'Screenshots at mobile (375x812), tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.', usage: 'responsive [prefix]' }, 'diff': { category: 'Visual', description: 'Text diff between pages', usage: 'diff ' }, @@ -161,3 +163,100 @@ for (const cmd of allCmds) { for (const key of descKeys) { if (!allCmds.has(key)) throw new Error(`COMMAND_DESCRIPTIONS has unknown command: ${key}`); } + +/** + * Command aliases — user-friendly names that route to canonical commands. + * + * Single source of truth: server.ts dispatch and meta-commands.ts chain prevalidation + * both import `canonicalizeCommand()`, so aliases resolve identically everywhere. + * + * When adding a new alias: keep the alias name guessable (e.g. setcontent → load-html + * helps agents migrating from Puppeteer's page.setContent()). + */ +export const COMMAND_ALIASES: Record = { + 'setcontent': 'load-html', + 'set-content': 'load-html', + 'setContent': 'load-html', +}; + +/** Resolve an alias to its canonical command name. Non-aliases pass through unchanged. */ +export function canonicalizeCommand(cmd: string): string { + return COMMAND_ALIASES[cmd] ?? cmd; +} + +/** + * Commands added in specific versions — enables future "this command was added in vX" + * upgrade hints in unknown-command errors. Only helps agents on *newer* browse builds + * that encounter typos of recently-added commands; does NOT help agents on old builds + * that type a new command (they don't have this map). + */ +export const NEW_IN_VERSION: Record = { + 'load-html': '0.19.0.0', +}; + +/** + * Levenshtein distance (dynamic programming). + * O(a.length * b.length) — fast for command name sizes (<20 chars). + */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const m: number[][] = []; + for (let i = 0; i <= a.length; i++) m.push([i, ...Array(b.length).fill(0)]); + for (let j = 0; j <= b.length; j++) m[0][j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + cost); + } + } + return m[a.length][b.length]; +} + +/** + * Build an actionable error message for an unknown command. + * + * Pure function — takes the full command set + alias map + version map as args so tests + * can exercise the synthetic "older-version" case without mutating any global state. + * + * 1. Always names the input. + * 2. If Levenshtein distance ≤ 2 AND input.length ≥ 4, suggests the closest match + * (alphabetical tiebreak for determinism). Short-input guard prevents noisy + * suggestions for typos of 2-letter commands like 'js' or 'is'. + * 3. If the input appears in newInVersion, appends an upgrade hint. Honesty caveat: + * this only fires on builds that have this handler AND the map entry; agents on + * older builds hitting a newly-added command won't see it. Net benefit compounds + * as more commands land. + */ +export function buildUnknownCommandError( + command: string, + commandSet: Set, + aliasMap: Record = COMMAND_ALIASES, + newInVersion: Record = NEW_IN_VERSION, +): string { + let msg = `Unknown command: '${command}'.`; + + // Suggestion via Levenshtein, gated on input length to avoid noisy short-input matches. + if (command.length >= 4) { + let best: string | undefined; + let bestDist = 3; + const candidates = [...commandSet, ...Object.keys(aliasMap)].sort(); + for (const cand of candidates) { + const d = levenshtein(command, cand); + if (d < bestDist || (d === bestDist && best !== undefined && cand < best)) { + if (d <= 2) { + best = cand; + bestDist = d; + } + } + } + if (best) msg += ` Did you mean '${best}'?`; + } + + if (newInVersion[command]) { + msg += ` This command was added in browse v${newInVersion[command]}. Upgrade: cd ~/.claude/skills/gstack && git pull && bun run build.`; + } + + return msg; +} diff --git a/browse/src/tab-session.ts b/browse/src/tab-session.ts index e5e8279a..06a00c4b 100644 --- a/browse/src/tab-session.ts +++ b/browse/src/tab-session.ts @@ -24,6 +24,8 @@ export interface RefEntry { name: string; } +export type SetContentWaitUntil = 'load' | 'domcontentloaded' | 'networkidle'; + export class TabSession { readonly page: Page; @@ -37,6 +39,30 @@ export class TabSession { // ─── Frame context ───────────────────────────────────────── private activeFrame: Frame | null = null; + // ─── Loaded HTML (for load-html replay across context recreation) ─ + // + // loadedHtml lifecycle: + // + // load-html cmd ──▶ session.setTabContent(html, opts) + // ├─▶ page.setContent(html, opts) + // └─▶ this.loadedHtml = html + // this.loadedHtmlWaitUntil = opts.waitUntil + // + // goto/back/forward/reload ──▶ session.clearLoadedHtml() + // (BEFORE Playwright call, so timeouts + // don't leave stale state) + // + // viewport --scale ──▶ recreateContext() + // ├─▶ saveState() captures { url, loadedHtml } per tab + // │ (in-memory only, never to disk) + // └─▶ restoreState(): + // for each tab with loadedHtml: + // newSession.setTabContent(html, opts) + // (NOT page.setContent — must rehydrate + // TabSession.loadedHtml too) + private loadedHtml: string | null = null; + private loadedHtmlWaitUntil: SetContentWaitUntil | undefined; + constructor(page: Page) { this.page = page; } @@ -137,4 +163,31 @@ export class TabSession { this.clearRefs(); this.activeFrame = null; } + + // ─── Loaded HTML (load-html replay) ─────────────────────── + + /** + * Load HTML content into the tab AND store it for replay after context recreation + * (e.g. viewport --scale). Unlike page.setContent() alone, this rehydrates + * TabSession.loadedHtml so the next saveState()/restoreState() round-trip preserves + * the content. + */ + async setTabContent(html: string, opts: { waitUntil?: SetContentWaitUntil } = {}): Promise { + const waitUntil = opts.waitUntil ?? 'domcontentloaded'; + this.loadedHtml = html; + this.loadedHtmlWaitUntil = waitUntil; + await this.page.setContent(html, { waitUntil, timeout: 15000 }); + } + + /** Get stored HTML + waitUntil for state replay. Returns null if no load-html happened. */ + getLoadedHtml(): { html: string; waitUntil?: SetContentWaitUntil } | null { + if (this.loadedHtml === null) return null; + return { html: this.loadedHtml, waitUntil: this.loadedHtmlWaitUntil }; + } + + /** Clear stored HTML. Called BEFORE goto/back/forward/reload navigation. */ + clearLoadedHtml(): void { + this.loadedHtml = null; + this.loadedHtmlWaitUntil = undefined; + } } diff --git a/browse/src/token-registry.ts b/browse/src/token-registry.ts index 56d3234d..455391eb 100644 --- a/browse/src/token-registry.ts +++ b/browse/src/token-registry.ts @@ -46,6 +46,7 @@ export const SCOPE_READ = new Set([ /** Commands that modify page state or navigate */ export const SCOPE_WRITE = new Set([ 'goto', 'back', 'forward', 'reload', + 'load-html', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload', 'viewport', 'newtab', 'closetab', 'dialog-accept', 'dialog-dismiss', diff --git a/browse/test/dx-polish.test.ts b/browse/test/dx-polish.test.ts new file mode 100644 index 00000000..800a422a --- /dev/null +++ b/browse/test/dx-polish.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'bun:test'; +import { + canonicalizeCommand, + COMMAND_ALIASES, + NEW_IN_VERSION, + buildUnknownCommandError, + ALL_COMMANDS, +} from '../src/commands'; + +describe('canonicalizeCommand', () => { + it('resolves setcontent → load-html', () => { + expect(canonicalizeCommand('setcontent')).toBe('load-html'); + }); + + it('resolves set-content → load-html', () => { + expect(canonicalizeCommand('set-content')).toBe('load-html'); + }); + + it('resolves setContent → load-html (case-sensitive key)', () => { + expect(canonicalizeCommand('setContent')).toBe('load-html'); + }); + + it('passes canonical names through unchanged', () => { + expect(canonicalizeCommand('load-html')).toBe('load-html'); + expect(canonicalizeCommand('goto')).toBe('goto'); + }); + + it('passes unknown names through unchanged (alias map is allowlist, not filter)', () => { + expect(canonicalizeCommand('totally-made-up')).toBe('totally-made-up'); + }); +}); + +describe('buildUnknownCommandError', () => { + it('names the input in every error', () => { + const msg = buildUnknownCommandError('xyz', ALL_COMMANDS); + expect(msg).toContain(`Unknown command: 'xyz'`); + }); + + it('suggests closest match within Levenshtein 2 when input length >= 4', () => { + const msg = buildUnknownCommandError('load-htm', ALL_COMMANDS); + expect(msg).toContain(`Did you mean 'load-html'?`); + }); + + it('does NOT suggest for short inputs (< 4 chars, avoids noise on js/is typos)', () => { + // 'j' is distance 1 from 'js' but only 1 char — suggestion would be noisy + const msg = buildUnknownCommandError('j', ALL_COMMANDS); + expect(msg).not.toContain('Did you mean'); + }); + + it('uses alphabetical tiebreak for deterministic suggestions', () => { + // Synthetic command set where two commands tie on distance from input + const syntheticSet = new Set(['alpha', 'beta']); + // 'alpha' vs 'delta' = 3 edits; 'beta' vs 'delta' = 2 edits + // Let's use a case that genuinely ties. + const ties = new Set(['abcd', 'abce']); // both distance 1 from 'abcf' + const msg = buildUnknownCommandError('abcf', ties, {}, {}); + // Alphabetical first: 'abcd' comes before 'abce' + expect(msg).toContain(`Did you mean 'abcd'?`); + }); + + it('appends upgrade hint when command appears in NEW_IN_VERSION', () => { + // Synthetic: pretend load-html isn't in the command set (agent on older build) + const noLoadHtml = new Set([...ALL_COMMANDS].filter(c => c !== 'load-html')); + const msg = buildUnknownCommandError('load-html', noLoadHtml, COMMAND_ALIASES, NEW_IN_VERSION); + expect(msg).toContain('added in browse v'); + expect(msg).toContain('Upgrade:'); + }); + + it('omits upgrade hint for unknown commands not in NEW_IN_VERSION', () => { + const msg = buildUnknownCommandError('notarealcommand', ALL_COMMANDS); + expect(msg).not.toContain('added in browse v'); + }); + + it('NEW_IN_VERSION has load-html entry', () => { + expect(NEW_IN_VERSION['load-html']).toBeTruthy(); + }); + + it('COMMAND_ALIASES + command set are consistent — all alias targets exist', () => { + for (const target of Object.values(COMMAND_ALIASES)) { + expect(ALL_COMMANDS.has(target)).toBe(true); + } + }); +}); + +describe('Alias + SCOPE_WRITE integration invariant', () => { + it('load-html is in SCOPE_WRITE (alias canonicalization happens before scope check)', async () => { + const { SCOPE_WRITE } = await import('../src/token-registry'); + expect(SCOPE_WRITE.has('load-html')).toBe(true); + }); + + it('setcontent is NOT directly in any scope set (must canonicalize first)', async () => { + const { SCOPE_WRITE, SCOPE_READ, SCOPE_ADMIN, SCOPE_CONTROL } = await import('../src/token-registry'); + // The alias itself must NOT appear in any scope set — only the canonical form. + // This proves scope enforcement relies on canonicalization at dispatch time, + // not on the alias leaking through as an acceptable command. + expect(SCOPE_WRITE.has('setcontent')).toBe(false); + expect(SCOPE_READ.has('setcontent')).toBe(false); + expect(SCOPE_ADMIN.has('setcontent')).toBe(false); + expect(SCOPE_CONTROL.has('setcontent')).toBe(false); + }); +});