mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-20 11:19:56 +08:00
merge: integrate origin/main (v0.18.1.0) into open-agents-learnings
Main moved forward 6 commits while this branch was local. Integrated
both sides preserving all functionality:
From main (v0.16.4.0 → v0.18.1.0):
- v0.17.0.0 — UX behavioral foundations + ux-audit (generateUXPrinciples,
{{UX_PRINCIPLES}} placeholder, triggers frontmatter on skills)
- v0.18.0.0 — Confusion Protocol, Hermes + GBrain hosts, brain-first
resolver (generateBrainHealthInstruction, generateConfusionProtocol,
generateGBrainContextLoad, generateGBrainSaveResults, hosts/gbrain.ts,
hosts/hermes.ts, scripts/resolvers/gbrain.ts, GBrain bash health check)
- v0.18.0.1 — ngrok Windows build fix
- 0cc830b6 — tilde-in-assignment permission fix
- cc42f14a — gstack compact design doc (tabled)
- 822e843a — headed browser auto-shutdown + disconnect cleanup (v0.18.1.0)
Integration approach: keep this branch's preamble.ts submodule refactor
as the structure of record. Extracted main's two new generators into
their own submodules:
- scripts/resolvers/preamble/generate-brain-health-instruction.ts
- scripts/resolvers/preamble/generate-confusion-protocol.ts
Updated scripts/resolvers/preamble/generate-preamble-bash.ts to absorb
main's GBrain health check (host-conditional on gbrain/hermes).
scripts/resolvers/index.ts now imports BOTH:
- This branch's adds: MODEL_OVERLAY, TASTE_PROFILE, BIN_DIR resolvers
- Main's adds: UX_PRINCIPLES, GBRAIN_CONTEXT_LOAD, GBRAIN_SAVE_RESULTS
resolvers
scripts/resolvers/design.ts keeps both generateTasteProfile (this
branch) and generateUXPrinciples (main). Sibling exports, no overlap.
scripts/gen-skill-docs.ts keeps both this branch's --model flag wiring
and main's edits.
Templates auto-merged where possible. The 35 generated SKILL.md /
golden conflicts auto-resolved via `bun run gen:skill-docs --host all`
followed by re-snapshotting the ship goldens for claude/codex/factory.
Verification:
- bun run gen:skill-docs --host all completes cleanly
- bun test: 1 pre-existing failure (gstack-community-dashboard Supabase
network test, 235s timeout). NOT related to merge — unchanged Supabase
test infra times out without live network. Flagged in PR body.
Token-ceiling warnings on plan-ceo-review (29K), office-hours (26K),
and ship (34K). These existed on origin/main before the merge — the
preamble grew substantially from main's GBrain + UX additions plus this
branch's continuous-checkpoint, context-health, model-overlay, taste-profile,
and feature-discovery additions. Worth a follow-up reduction pass but
doesn't block this merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,10 @@ description: |
|
||||
~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
|
||||
user flow, or file a bug with evidence. Use when asked to "open in browser", "test the
|
||||
site", "take a screenshot", or "dogfood this". (gstack)
|
||||
triggers:
|
||||
- browse a page
|
||||
- headless browser
|
||||
- take page screenshot
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -488,7 +492,7 @@ State persists between calls (cookies, tabs, login sessions).
|
||||
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
B=""
|
||||
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
|
||||
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse
|
||||
[ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
|
||||
if [ -x "$B" ]; then
|
||||
echo "READY: $B"
|
||||
else
|
||||
@@ -642,6 +646,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
|
||||
-a --annotate Annotated screenshot with red overlay boxes and ref labels
|
||||
-o <path> --output Output path for annotated screenshot (default: <temp>/browse-annotated.png)
|
||||
-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.
|
||||
-H <json> --heatmap Color-coded overlay screenshot from JSON map: '{"@e1":"green","@e3":"red"}'. Valid colors: green, yellow, red, blue, orange, gray.
|
||||
```
|
||||
|
||||
All flags can be combined freely. `-o` only applies when `-a` is also used.
|
||||
@@ -772,6 +777,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| `network [--clear]` | Network requests |
|
||||
| `perf` | Page load timings |
|
||||
| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage |
|
||||
| `ux-audit` | Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation. |
|
||||
|
||||
### Visual
|
||||
| Command | Description |
|
||||
|
||||
@@ -9,6 +9,10 @@ description: |
|
||||
~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
|
||||
user flow, or file a bug with evidence. Use when asked to "open in browser", "test the
|
||||
site", "take a screenshot", or "dogfood this". (gstack)
|
||||
triggers:
|
||||
- browse a page
|
||||
- headless browser
|
||||
- take page screenshot
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
|
||||
@@ -14,13 +14,19 @@ DIST_DIR="$GSTACK_DIR/browse/dist"
|
||||
echo "Building Node-compatible server bundle..."
|
||||
|
||||
# Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps)
|
||||
#
|
||||
# Externalize packages with native addons, dynamic imports, or runtime resolution.
|
||||
# If you add a new dependency that uses `await import()` or has a .node addon,
|
||||
# add it here. Otherwise `bun build --outfile` will fail with
|
||||
# "cannot write multiple output files without an output directory".
|
||||
bun build "$SRC_DIR/server.ts" \
|
||||
--target=node \
|
||||
--outfile "$DIST_DIR/server-node.mjs" \
|
||||
--external playwright \
|
||||
--external playwright-core \
|
||||
--external diff \
|
||||
--external "bun:sqlite"
|
||||
--external "bun:sqlite" \
|
||||
--external "@ngrok/ngrok"
|
||||
|
||||
# Step 2: Post-process
|
||||
# Replace import.meta.dir with a resolvable reference
|
||||
|
||||
@@ -72,6 +72,12 @@ export class BrowserManager {
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
// Called when the headed browser disconnects without intentional teardown
|
||||
// (user closed the window). Wired up by server.ts to run full cleanup
|
||||
// (sidebar-agent, state file, profile locks) before exiting with code 2.
|
||||
// Returns void or a Promise; rejections are caught and fall back to exit(2).
|
||||
public onDisconnect: (() => void | Promise<void>) | null = null;
|
||||
|
||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||||
|
||||
// ─── Watch Mode Methods ─────────────────────────────────
|
||||
@@ -467,13 +473,32 @@ export class BrowserManager {
|
||||
await this.newTab();
|
||||
}
|
||||
|
||||
// Browser disconnect handler — exit code 2 distinguishes from crashes (1)
|
||||
// Browser disconnect handler — exit code 2 distinguishes from crashes (1).
|
||||
// Calls onDisconnect() to trigger full shutdown (kill sidebar-agent, save
|
||||
// session, clean profile locks + state file) before exit. Falls back to
|
||||
// direct process.exit(2) if no callback is wired up, or if the callback
|
||||
// throws/rejects — never leave the process running with a dead browser.
|
||||
if (this.browser) {
|
||||
this.browser.on('disconnected', () => {
|
||||
if (this.intentionalDisconnect) return;
|
||||
console.error('[browse] Real browser disconnected (user closed or crashed).');
|
||||
console.error('[browse] Run `$B connect` to reconnect.');
|
||||
process.exit(2);
|
||||
if (!this.onDisconnect) {
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = this.onDisconnect();
|
||||
if (result && typeof (result as Promise<void>).catch === 'function') {
|
||||
(result as Promise<void>).catch((err) => {
|
||||
console.error('[browse] onDisconnect rejected:', err);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[browse] onDisconnect threw:', err);
|
||||
process.exit(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -210,12 +210,20 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
|
||||
let proc: any = null;
|
||||
|
||||
// Allow the caller to opt out of the parent-process watchdog by setting
|
||||
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
|
||||
// shells, and short-lived Bash invocations that need the server to outlive
|
||||
// the spawning CLI. Defaults to the current process PID (watchdog active).
|
||||
// Parse as int so stray whitespace ("0\n") still opts out — matches the
|
||||
// server's own parseInt at server.ts:760.
|
||||
const parentPid = parseInt(process.env.BROWSE_PARENT_PID || '', 10) === 0 ? '0' : String(process.pid);
|
||||
|
||||
if (IS_WINDOWS && NODE_SERVER_SCRIPT) {
|
||||
// Windows: Bun.spawn() + proc.unref() doesn't truly detach on Windows —
|
||||
// when the CLI exits, the server dies with it. Use Node's child_process.spawn
|
||||
// with { detached: true } instead, which is the gold standard for Windows
|
||||
// process independence. Credit: PR #191 by @fqueiro.
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...(extraEnv || {}) });
|
||||
const extraEnvStr = JSON.stringify({ BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...(extraEnv || {}) });
|
||||
const launcherCode =
|
||||
`const{spawn}=require('child_process');` +
|
||||
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
|
||||
@@ -226,7 +234,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: String(process.pid), ...extraEnv },
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
@@ -826,12 +834,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
BROWSE_HEADED: '1',
|
||||
BROWSE_PORT: '34567',
|
||||
BROWSE_SIDEBAR_CHAT: '1',
|
||||
// Disable parent-process watchdog: the user controls the headed browser
|
||||
// window lifecycle. The CLI exits immediately after connect, so watching
|
||||
// it would kill the server ~15s later. Cleanup happens via browser
|
||||
// disconnect event or $B disconnect.
|
||||
BROWSE_PARENT_PID: '0',
|
||||
};
|
||||
// If parent explicitly set BROWSE_PARENT_PID=0 (pair-agent disabling
|
||||
// self-termination), pass it through so startServer doesn't override it.
|
||||
if (process.env.BROWSE_PARENT_PID === '0') {
|
||||
serverEnv.BROWSE_PARENT_PID = '0';
|
||||
}
|
||||
const newState = await startServer(serverEnv);
|
||||
|
||||
// Print connected status
|
||||
|
||||
@@ -40,6 +40,7 @@ export const META_COMMANDS = new Set([
|
||||
'watch',
|
||||
'state',
|
||||
'frame',
|
||||
'ux-audit',
|
||||
]);
|
||||
|
||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||
@@ -49,6 +50,7 @@ export const PAGE_CONTENT_COMMANDS = new Set([
|
||||
'text', 'html', 'links', 'forms', 'accessibility', 'attrs',
|
||||
'console', 'dialog',
|
||||
'media', 'data',
|
||||
'ux-audit',
|
||||
]);
|
||||
|
||||
/** Wrap output from untrusted-content commands with trust boundary markers */
|
||||
@@ -146,6 +148,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style <sel> <prop> <value> | style --undo [N]' },
|
||||
'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' },
|
||||
'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' },
|
||||
// UX Audit
|
||||
'ux-audit': { category: 'Inspection', description: 'Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation.', usage: 'ux-audit' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
@@ -653,6 +653,116 @@ export async function handleMetaCommand(
|
||||
return `Switched to frame: ${frame.url()}`;
|
||||
}
|
||||
|
||||
// ─── UX Audit ─────────────────────────────────────
|
||||
case 'ux-audit': {
|
||||
const page = bm.getPage();
|
||||
|
||||
// Extract page structure for UX behavioral analysis
|
||||
// Agent interprets the data and applies Krug's 6 usability tests
|
||||
// Uses textContent (not innerText) to avoid layout computation on large DOMs
|
||||
const data = await page.evaluate(() => {
|
||||
const HEADING_CAP = 50;
|
||||
const INTERACTIVE_CAP = 200;
|
||||
const TEXT_BLOCK_CAP = 50;
|
||||
|
||||
// Site ID: logo or brand element
|
||||
const logoEl = document.querySelector('[class*="logo"], [id*="logo"], header img, [aria-label*="home"], a[href="/"]');
|
||||
const siteId = logoEl ? {
|
||||
found: true,
|
||||
text: (logoEl.textContent || '').trim().slice(0, 100),
|
||||
tag: logoEl.tagName,
|
||||
alt: (logoEl as HTMLImageElement).alt || null,
|
||||
} : { found: false, text: null, tag: null, alt: null };
|
||||
|
||||
// Page name: main heading
|
||||
const h1 = document.querySelector('h1');
|
||||
const pageName = h1 ? {
|
||||
found: true,
|
||||
text: h1.textContent?.trim().slice(0, 200) || '',
|
||||
} : { found: false, text: null };
|
||||
|
||||
// Navigation: primary nav elements
|
||||
const navEls = document.querySelectorAll('nav, [role="navigation"]');
|
||||
const navItems: Array<{ text: string; links: number }> = [];
|
||||
navEls.forEach((nav, i) => {
|
||||
if (i >= 5) return;
|
||||
const links = nav.querySelectorAll('a');
|
||||
navItems.push({
|
||||
text: (nav.getAttribute('aria-label') || `nav-${i}`).slice(0, 50),
|
||||
links: links.length,
|
||||
});
|
||||
});
|
||||
|
||||
// "You are here" indicator: current/active nav items
|
||||
// Scoped to nav containers to avoid false positives from animation classes
|
||||
const activeNavItems = document.querySelectorAll('nav [aria-current], nav .active, nav .current, [role="navigation"] [aria-current], [role="navigation"] .active, [role="navigation"] .current');
|
||||
const youAreHere = Array.from(activeNavItems).slice(0, 5).map(el => ({
|
||||
text: (el.textContent || '').trim().slice(0, 50),
|
||||
tag: el.tagName,
|
||||
}));
|
||||
|
||||
// Search: search box presence
|
||||
const searchEl = document.querySelector('input[type="search"], [role="search"], input[name*="search"], input[placeholder*="search" i], input[aria-label*="search" i]');
|
||||
const search = { found: !!searchEl };
|
||||
|
||||
// Breadcrumbs
|
||||
const breadcrumbEl = document.querySelector('[aria-label*="breadcrumb" i], .breadcrumb, .breadcrumbs, [class*="breadcrumb"]');
|
||||
const breadcrumbs = breadcrumbEl ? {
|
||||
found: true,
|
||||
items: Array.from(breadcrumbEl.querySelectorAll('a, span, li')).slice(0, 10).map(el => (el.textContent || '').trim().slice(0, 30)),
|
||||
} : { found: false, items: [] };
|
||||
|
||||
// Headings: heading hierarchy
|
||||
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).slice(0, HEADING_CAP).map(h => ({
|
||||
tag: h.tagName,
|
||||
text: (h.textContent || '').trim().slice(0, 80),
|
||||
size: getComputedStyle(h).fontSize,
|
||||
}));
|
||||
|
||||
// Interactive elements: buttons, links, inputs
|
||||
const interactiveEls = Array.from(document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]')).slice(0, INTERACTIVE_CAP);
|
||||
const interactive = interactiveEls.map(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
tag: el.tagName,
|
||||
text: (el.textContent || (el as HTMLInputElement).placeholder || '').trim().slice(0, 50),
|
||||
type: (el as HTMLInputElement).type || null,
|
||||
role: el.getAttribute('role'),
|
||||
w: Math.round(rect.width),
|
||||
h: Math.round(rect.height),
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
}).filter(el => el.visible);
|
||||
|
||||
// Text blocks: paragraphs and large text areas
|
||||
const textBlocks = Array.from(document.querySelectorAll('p, [class*="description"], [class*="intro"], [class*="welcome"], [class*="hero"] p, main p')).slice(0, TEXT_BLOCK_CAP).map(el => ({
|
||||
text: (el.textContent || '').trim().slice(0, 200),
|
||||
wordCount: (el.textContent || '').trim().split(/\s+/).filter(Boolean).length,
|
||||
}));
|
||||
|
||||
// Total visible text word count (textContent avoids layout computation)
|
||||
const bodyText = (document.body?.textContent || '').trim();
|
||||
const totalWords = bodyText.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
siteId,
|
||||
pageName,
|
||||
navigation: navItems,
|
||||
youAreHere,
|
||||
search,
|
||||
breadcrumbs,
|
||||
headings,
|
||||
interactive,
|
||||
textBlocks,
|
||||
totalWords,
|
||||
};
|
||||
});
|
||||
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -757,8 +757,16 @@ const idleCheckInterval = setInterval(() => {
|
||||
// server can become an orphan — keeping chrome-headless-shell alive and
|
||||
// causing console-window flicker on Windows. Poll the parent PID every 15s
|
||||
// and self-terminate if it is gone.
|
||||
//
|
||||
// Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls
|
||||
// the browser window lifecycle. The CLI exits immediately after connect,
|
||||
// so the watchdog would kill the server prematurely. Disabled in both cases
|
||||
// as defense-in-depth — the CLI sets PID=0 for headed mode, and the server
|
||||
// also checks BROWSE_HEADED in case a future launcher forgets.
|
||||
// Cleanup happens via browser disconnect event or $B disconnect.
|
||||
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||||
if (BROWSE_PARENT_PID > 0) {
|
||||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||||
setInterval(() => {
|
||||
try {
|
||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||
@@ -767,6 +775,10 @@ if (BROWSE_PARENT_PID > 0) {
|
||||
shutdown();
|
||||
}
|
||||
}, 15_000);
|
||||
} else if (IS_HEADED_WATCHDOG) {
|
||||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||||
} else if (BROWSE_PARENT_PID === 0) {
|
||||
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||
}
|
||||
|
||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||
@@ -793,6 +805,10 @@ function emitInspectorEvent(event: any): void {
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
// When the user closes the headed browser window, run full cleanup
|
||||
// (kill sidebar-agent, save session, remove profile locks, delete state file)
|
||||
// before exiting with code 2. Exit code 2 distinguishes user-close from crashes (1).
|
||||
browserManager.onDisconnect = () => shutdown(2);
|
||||
let isShuttingDown = false;
|
||||
|
||||
// Test if a port is available by binding and immediately releasing.
|
||||
@@ -1180,7 +1196,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
async function shutdown(exitCode: number = 0) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
@@ -1221,12 +1237,15 @@ async function shutdown() {
|
||||
// Clean up state file
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
|
||||
process.exit(0);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Handle signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
|
||||
// Wrap so shutdown() receives no args — otherwise the string gets passed as
|
||||
// exitCode and process.exit() coerces it to NaN, exiting with code 1 instead of 0.
|
||||
process.on('SIGTERM', () => shutdown());
|
||||
process.on('SIGINT', () => shutdown());
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SnapshotOptions {
|
||||
annotate?: boolean; // -a / --annotate: annotated screenshot
|
||||
outputPath?: string; // -o / --output: path for annotated screenshot
|
||||
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
|
||||
heatmap?: string; // -H / --heatmap: JSON color map for ref overlays
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ export const SNAPSHOT_FLAGS: Array<{
|
||||
{ short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
|
||||
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
|
||||
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.', optionKey: 'cursorInteractive' },
|
||||
{ short: '-H', long: '--heatmap', description: 'Color-coded overlay screenshot from JSON map: \'{"@e1":"green","@e3":"red"}\'. Valid colors: green, yellow, red, blue, orange, gray.', takesValue: true, valueHint: '<json>', optionKey: 'heatmap' },
|
||||
];
|
||||
|
||||
interface ParsedNode {
|
||||
@@ -435,6 +437,124 @@ export async function handleSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Heatmap mode (-H) ──────────────────────────────────────
|
||||
if (opts.heatmap) {
|
||||
const heatmapPath = opts.outputPath || `${TEMP_DIR}/browse-heatmap.png`;
|
||||
// Validate output path
|
||||
{
|
||||
const nodePath = require('path') as typeof import('path');
|
||||
const nodeFs = require('fs') as typeof import('fs');
|
||||
const absolute = nodePath.resolve(heatmapPath);
|
||||
const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
|
||||
try { return nodeFs.realpathSync(d); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; return d; }
|
||||
});
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = nodeFs.realpathSync(absolute);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
try {
|
||||
const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
|
||||
realPath = nodePath.join(dir, nodePath.basename(absolute));
|
||||
} catch (err2: any) {
|
||||
if (err2?.code !== 'ENOENT') throw err2;
|
||||
realPath = absolute;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Cannot resolve real path: ${heatmapPath} (${err.code})`);
|
||||
}
|
||||
}
|
||||
if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
|
||||
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate color map
|
||||
const VALID_COLORS = new Set(['green', 'yellow', 'red', 'blue', 'orange', 'gray']);
|
||||
const COLOR_MAP: Record<string, { border: string; bg: string }> = {
|
||||
green: { border: '#00b400', bg: 'rgba(0,180,0,0.15)' },
|
||||
yellow: { border: '#ffb400', bg: 'rgba(255,180,0,0.15)' },
|
||||
red: { border: '#ff0000', bg: 'rgba(255,0,0,0.15)' },
|
||||
blue: { border: '#0066ff', bg: 'rgba(0,102,255,0.15)' },
|
||||
orange: { border: '#ff6600', bg: 'rgba(255,102,0,0.15)' },
|
||||
gray: { border: '#888888', bg: 'rgba(136,136,136,0.15)' },
|
||||
};
|
||||
|
||||
let colorAssignments: Record<string, string>;
|
||||
try {
|
||||
const parsed = JSON.parse(opts.heatmap);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('not an object');
|
||||
}
|
||||
colorAssignments = parsed;
|
||||
} catch {
|
||||
throw new Error('Invalid heatmap JSON. Expected object: \'{"@e1":"green","@e3":"red"}\'');
|
||||
}
|
||||
|
||||
// Validate colors
|
||||
for (const [ref, color] of Object.entries(colorAssignments)) {
|
||||
if (!VALID_COLORS.has(color)) {
|
||||
throw new Error(`Invalid heatmap color "${color}" for ${ref}. Valid: ${[...VALID_COLORS].join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number }; color: string }> = [];
|
||||
for (const [refKey, color] of Object.entries(colorAssignments)) {
|
||||
const cleanRef = refKey.startsWith('@') ? refKey.slice(1) : refKey;
|
||||
const entry = refMap.get(cleanRef);
|
||||
if (!entry) continue; // Skip refs not found on page
|
||||
try {
|
||||
const box = await entry.locator.boundingBox({ timeout: 1000 });
|
||||
if (box) {
|
||||
const colors = COLOR_MAP[color] || COLOR_MAP.gray;
|
||||
boxes.push({ ref: `@${cleanRef}`, box, color: JSON.stringify(colors) });
|
||||
}
|
||||
} catch {
|
||||
// Element may be offscreen or hidden — skip
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate((boxes) => {
|
||||
for (const { ref, box, color } of boxes) {
|
||||
const colors = JSON.parse(color);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = '__browse_heatmap__';
|
||||
overlay.style.cssText = `
|
||||
position: absolute; top: ${box.y}px; left: ${box.x}px;
|
||||
width: ${box.width}px; height: ${box.height}px;
|
||||
border: 2px solid ${colors.border}; background: ${colors.bg};
|
||||
pointer-events: none; z-index: 99999;
|
||||
font-size: 10px; color: ${colors.border}; font-weight: bold;
|
||||
`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = ref;
|
||||
label.style.cssText = `position: absolute; top: -14px; left: 0; background: ${colors.border}; color: white; padding: 0 3px; font-size: 10px;`;
|
||||
overlay.appendChild(label);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: heatmapPath, fullPage: true });
|
||||
|
||||
// Remove heatmap overlays
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
output.push('');
|
||||
output.push(`[heatmap screenshot: ${heatmapPath}]`);
|
||||
} catch (err: any) {
|
||||
// Cleanup on failure
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
|
||||
});
|
||||
} catch {}
|
||||
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context') && !err?.message?.includes('screenshot')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diff mode (-D) ───────────────────────────────────────
|
||||
if (opts.diff) {
|
||||
const lastSnapshot = session.getLastSnapshot();
|
||||
|
||||
28
browse/test/build.test.ts
Normal file
28
browse/test/build.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DIST_DIR = path.resolve(__dirname, '..', 'dist');
|
||||
const SERVER_NODE = path.join(DIST_DIR, 'server-node.mjs');
|
||||
|
||||
describe('build: server-node.mjs', () => {
|
||||
test('passes node --check if present', () => {
|
||||
if (!fs.existsSync(SERVER_NODE)) {
|
||||
// browse/dist is gitignored; no build has run in this checkout.
|
||||
// Skip rather than fail so plain `bun test` without a prior build passes.
|
||||
return;
|
||||
}
|
||||
expect(() => execSync(`node --check ${SERVER_NODE}`, { stdio: 'pipe' })).not.toThrow();
|
||||
});
|
||||
|
||||
test('does not inline @ngrok/ngrok (must be external)', () => {
|
||||
if (!fs.existsSync(SERVER_NODE)) return;
|
||||
const bundle = fs.readFileSync(SERVER_NODE, 'utf-8');
|
||||
// Dynamic imports of externalized packages show up as string literals in the bundle,
|
||||
// not as inlined module code. The heuristic: ngrok's native binding loader would
|
||||
// reference its own internals. If any ngrok internal identifier appears, the module
|
||||
// got inlined despite the --external flag.
|
||||
expect(bundle).not.toMatch(/ngrok_napi|ngrokNapi|@ngrok\/ngrok-darwin|@ngrok\/ngrok-linux|@ngrok\/ngrok-win32/);
|
||||
});
|
||||
});
|
||||
147
browse/test/watchdog.test.ts
Normal file
147
browse/test/watchdog.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { spawn, type Subprocess } from 'bun';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
// End-to-end regression tests for the parent-process watchdog in server.ts.
|
||||
// Proves three invariants that the v0.18.1.0 fix depends on:
|
||||
//
|
||||
// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent).
|
||||
// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth).
|
||||
// 3. Default headless mode still kills the server when its parent dies
|
||||
// (the original orphan-prevention must keep working).
|
||||
//
|
||||
// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the
|
||||
// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s
|
||||
// poll cycle to actually fire (slow — ~25s).
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
|
||||
|
||||
let tmpDir: string;
|
||||
let serverProc: Subprocess | null = null;
|
||||
let parentProc: Subprocess | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
// Kill any survivors so subsequent tests get a clean slate.
|
||||
try { parentProc?.kill('SIGKILL'); } catch {}
|
||||
try { serverProc?.kill('SIGKILL'); } catch {}
|
||||
// Give processes a moment to exit before tmpDir cleanup.
|
||||
await Bun.sleep(100);
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
parentProc = null;
|
||||
serverProc = null;
|
||||
});
|
||||
|
||||
function spawnServer(env: Record<string, string>, port: number): Subprocess {
|
||||
const stateFile = path.join(tmpDir, 'browse-state.json');
|
||||
return spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_PORT: String(port),
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0); // signal 0 = existence check, no signal sent
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Read stdout until we see the expected marker or timeout. Returns the captured
|
||||
// text. Used to verify the watchdog code path ran as expected at startup.
|
||||
async function readStdoutUntil(
|
||||
proc: Subprocess,
|
||||
marker: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const decoder = new TextDecoder();
|
||||
let captured = '';
|
||||
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const readPromise = reader.read();
|
||||
const timed = Bun.sleep(Math.max(0, deadline - Date.now()));
|
||||
const result = await Promise.race([readPromise, timed.then(() => null)]);
|
||||
if (!result || result.done) break;
|
||||
captured += decoder.decode(result.value);
|
||||
if (captured.includes(marker)) return captured;
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('parent-process watchdog (v0.18.1.0)', () => {
|
||||
test('BROWSE_PARENT_PID=0 disables the watchdog', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-'));
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901);
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
serverProc,
|
||||
'Parent-process watchdog disabled (BROWSE_PARENT_PID=0)',
|
||||
5000,
|
||||
);
|
||||
expect(out).toContain('Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||||
// Control: the "parent exited, shutting down" line must NOT appear —
|
||||
// that would mean the watchdog ran after we said to skip it.
|
||||
expect(out).not.toContain('Parent process');
|
||||
}, 15_000);
|
||||
|
||||
test('BROWSE_HEADED=1 disables the watchdog (server-side guard)', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-headed-'));
|
||||
// Pass a bogus parent PID to prove BROWSE_HEADED takes precedence.
|
||||
// If the server-side guard regresses, the watchdog would try to poll
|
||||
// this PID and eventually fire on the "dead parent."
|
||||
serverProc = spawnServer(
|
||||
{ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' },
|
||||
34902,
|
||||
);
|
||||
|
||||
const out = await readStdoutUntil(
|
||||
serverProc,
|
||||
'Parent-process watchdog disabled (headed mode)',
|
||||
5000,
|
||||
);
|
||||
expect(out).toContain('Parent-process watchdog disabled (headed mode)');
|
||||
expect(out).not.toContain('Parent process 999999 exited');
|
||||
}, 15_000);
|
||||
|
||||
test('default headless mode: watchdog fires when parent dies', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
|
||||
|
||||
// Spawn a real, short-lived "parent" that the watchdog will poll.
|
||||
parentProc = spawn(['sleep', '60'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
const parentPid = parentProc.pid!;
|
||||
|
||||
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
|
||||
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
|
||||
const serverPid = serverProc.pid!;
|
||||
|
||||
// Give the server a moment to start and register the watchdog interval.
|
||||
await Bun.sleep(2000);
|
||||
expect(isProcessAlive(serverPid)).toBe(true);
|
||||
|
||||
// Kill the parent. The watchdog polls every 15s, so first tick after
|
||||
// parent death lands within ~15s, plus shutdown() cleanup time.
|
||||
parentProc.kill('SIGKILL');
|
||||
|
||||
// Poll for up to 25s for the server to exit.
|
||||
const deadline = Date.now() + 25_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessAlive(serverPid)) break;
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
expect(isProcessAlive(serverPid)).toBe(false);
|
||||
}, 45_000);
|
||||
});
|
||||
Reference in New Issue
Block a user