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:
Garry Tan
2026-04-17 13:58:15 +08:00
129 changed files with 3314 additions and 154 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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}`);
}

View File

@@ -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') {

View File

@@ -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
View 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/);
});
});

View 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);
});