mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat(browse): TabSession loadedHtml + command aliases + DX polish primitives
Adds the foundation layer for Puppeteer-parity features: - TabSession.loadedHtml + setTabContent/getLoadedHtml/clearLoadedHtml — enables load-html content to survive context recreation (viewport --scale) via in-memory replay. ASCII lifecycle diagram in the source explains the clear-before-navigation contract. - COMMAND_ALIASES + canonicalizeCommand() helper — single source of truth for name aliases (setcontent / set-content / setContent → load-html), consumed by server dispatch and chain prevalidation. - buildUnknownCommandError() pure function — rich error messages with Levenshtein-based "Did you mean" suggestions (distance ≤ 2, input length ≥ 4 to skip 2-letter noise) and NEW_IN_VERSION upgrade hints. - load-html registered in WRITE_COMMANDS + SCOPE_WRITE so scoped write tokens can use it. - screenshot and viewport descriptions updated for upcoming flags. - New browse/test/dx-polish.test.ts (15 tests): alias canonicalization, Levenshtein threshold + alphabetical tiebreak, short-input guard, NEW_IN_VERSION upgrade hint, alias + scope integration invariants. No consumers yet — pure additive foundation. Safe to bisect on its own.
This commit is contained in:
+102
-3
@@ -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<string, { category: string; description: string; usage?: string }> = {
|
||||
// Navigation
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL', usage: 'goto <url>' },
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto <url>' },
|
||||
'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 <file> [--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<string, { category: string; descriptio
|
||||
'scroll': { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' },
|
||||
'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
|
||||
'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'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<string, { category: string; descriptio
|
||||
'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape <images|videos|media> [--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 <css>] [--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 <url1> <url2>' },
|
||||
@@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<string>,
|
||||
aliasMap: Record<string, string> = COMMAND_ALIASES,
|
||||
newInVersion: Record<string, string> = 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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user