mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-07 16:48:01 +02:00
Merge origin/main into /spec branch — retag v1.45.0.0 → v1.47.0.0
main moved to v1.46.0.0 (gstack v2 foundation, eval-first floor across 51 skills) while this branch was at v1.45.0.0. v1.46 also reserved v1.45.0.0 for the design daemon feature. Retag this branch's release v1.45.0.0 → v1.47.0.0 so it lands cleanly on top of main. Conflict resolutions: - VERSION: 1.47.0.0 (MINOR continues on top of main's 1.46.0.0; this branch is also a MINOR per scale-aware rules — new skill capability). - CHANGELOG: rewrite this branch's release header v1.45.0.0 → v1.47.0.0. Keep both main entries above main's older history. Adapts to main's eval-first floor (v1.46.0.0 test/skill-coverage-matrix.ts + test/skill-coverage-floor.test.ts): - Register /spec in SKILL_COVERAGE with 3 gate entries + 2 periodic. - Skill catalog grows 51 → 52. Floor 6/6 structural checks pass. - Catalog tokens: 4045 → 4116 (+71 for /spec, within v1.46's ≤7000 budget). - Trim spec frontmatter description to single-paragraph block form to respect v1.46's catalog-trim intent (was 14 lines / ~900 chars, now 5 lines / ~350 chars; routing prose stays in body sections). - 363/363 gate-tier tests pass across skill-coverage-floor (309) + skill-coverage-matrix (10) + skill-size-budget (3) + parity-suite (4) + spec-template-invariants (35) + spec-template-sync (2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-7
@@ -2,13 +2,7 @@
|
||||
name: browse
|
||||
preamble-tier: 1
|
||||
version: 1.1.0
|
||||
description: |
|
||||
Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
|
||||
elements, verify page state, diff before/after actions, take annotated screenshots, check
|
||||
responsive layouts, test forms and uploads, handle dialogs, and assert element states.
|
||||
~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)
|
||||
description: Fast headless browser for QA testing and site dogfooding. (gstack)
|
||||
triggers:
|
||||
- browse a page
|
||||
- headless browser
|
||||
@@ -22,6 +16,16 @@ allowed-tools:
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Navigate any URL, interact with
|
||||
elements, verify page state, diff before/after actions, take annotated screenshots, check
|
||||
responsive layouts, test forms and uploads, handle dialogs, and assert element states.
|
||||
~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".
|
||||
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
|
||||
+99
-14
@@ -650,6 +650,8 @@ export const __testInternals__ = {
|
||||
idleCheckTick,
|
||||
setTunnelActive: (v: boolean) => { tunnelActive = v; },
|
||||
setLastActivity: (t: number) => { lastActivity = t; },
|
||||
formatExplicitPortUnavailableError,
|
||||
formatRandomPortUnavailableError,
|
||||
// Reset the module-level shutdown latch so tests that drive shutdown to
|
||||
// completion (process.exit-stubbed) can be followed by tests that also
|
||||
// need shutdown to fire. Without this, the second test's shutdown
|
||||
@@ -752,41 +754,124 @@ let activeBrowserManager: BrowserManager = browserManager;
|
||||
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
||||
let isShuttingDown = false;
|
||||
|
||||
type PortCheckResult =
|
||||
| { available: true }
|
||||
| { available: false; code?: string; message: string };
|
||||
|
||||
type FailedPortAttempt = {
|
||||
port: number;
|
||||
result: Extract<PortCheckResult, { available: false }>;
|
||||
};
|
||||
|
||||
const RANDOM_PORT_MIN = 10000;
|
||||
const RANDOM_PORT_MAX = 60000;
|
||||
const RANDOM_PORT_RETRIES = 5;
|
||||
|
||||
function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
|
||||
const maybeNodeError = err as NodeJS.ErrnoException | undefined;
|
||||
return {
|
||||
available: false,
|
||||
code: maybeNodeError?.code,
|
||||
message: maybeNodeError?.message || String(err),
|
||||
};
|
||||
}
|
||||
|
||||
function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
|
||||
return result.code === 'EADDRINUSE';
|
||||
}
|
||||
|
||||
function formatPortFailureDetail(attempt: FailedPortAttempt): string {
|
||||
const { code, message } = attempt.result;
|
||||
return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
|
||||
}
|
||||
|
||||
function formatExplicitPortUnavailableError(
|
||||
port: number,
|
||||
result: Extract<PortCheckResult, { available: false }>
|
||||
): Error {
|
||||
if (isOccupiedPort(result)) {
|
||||
return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
|
||||
}
|
||||
|
||||
const detail = result.code ? `${result.code}: ${result.message}` : result.message;
|
||||
return new Error(
|
||||
`[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
|
||||
`This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
|
||||
`not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
|
||||
const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
|
||||
|
||||
if (blockingAttempts.length > 0) {
|
||||
const last = blockingAttempts[blockingAttempts.length - 1];
|
||||
return new Error(
|
||||
`[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
|
||||
`This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
|
||||
`not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
|
||||
`port, or run browse from an unrestricted terminal.`
|
||||
);
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
|
||||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
|
||||
);
|
||||
}
|
||||
|
||||
// Test if a port is available by binding and immediately releasing.
|
||||
// Uses net.createServer instead of Bun.serve to avoid a race condition
|
||||
// in the Node.js polyfill where listen/close are async but the caller
|
||||
// expects synchronous bind semantics. See: #486
|
||||
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
||||
function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.once('error', () => resolve(false));
|
||||
srv.listen(port, hostname, () => {
|
||||
srv.close(() => resolve(true));
|
||||
});
|
||||
let settled = false;
|
||||
const finish = (result: PortCheckResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
srv.once('error', (err) => finish(normalizePortError(err)));
|
||||
try {
|
||||
srv.listen(port, hostname, () => {
|
||||
srv.close(() => finish({ available: true }));
|
||||
});
|
||||
} catch (err) {
|
||||
finish(normalizePortError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
||||
return checkPortAvailable(port, hostname).then((result) => result.available);
|
||||
}
|
||||
|
||||
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
||||
async function findPort(): Promise<number> {
|
||||
// Explicit port override (for debugging)
|
||||
if (BROWSE_PORT) {
|
||||
if (await isPortAvailable(BROWSE_PORT)) {
|
||||
const result = await checkPortAvailable(BROWSE_PORT);
|
||||
if (result.available) {
|
||||
return BROWSE_PORT;
|
||||
}
|
||||
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
|
||||
throw formatExplicitPortUnavailableError(BROWSE_PORT, result);
|
||||
}
|
||||
|
||||
// Random port with retry
|
||||
const MIN_PORT = 10000;
|
||||
const MAX_PORT = 60000;
|
||||
const MAX_RETRIES = 5;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
|
||||
if (await isPortAvailable(port)) {
|
||||
const attempts: FailedPortAttempt[] = [];
|
||||
for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
|
||||
const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
|
||||
const result = await checkPortAvailable(port);
|
||||
if (result.available) {
|
||||
return port;
|
||||
}
|
||||
attempts.push({ port, result });
|
||||
}
|
||||
throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);
|
||||
throw formatRandomPortUnavailableError(attempts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import { __testInternals__ } from '../src/server';
|
||||
|
||||
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
|
||||
@@ -28,6 +29,47 @@ function getFreePort(): Promise<number> {
|
||||
}
|
||||
|
||||
describe('findPort / isPortAvailable', () => {
|
||||
test('explicit BROWSE_PORT diagnostic distinguishes bind denial from occupied port', () => {
|
||||
const blocked = __testInternals__.formatExplicitPortUnavailableError(34567, {
|
||||
available: false,
|
||||
code: 'EPERM',
|
||||
message: 'operation not permitted',
|
||||
}).message;
|
||||
|
||||
expect(blocked).toContain('Cannot bind BROWSE_PORT=34567');
|
||||
expect(blocked).toContain('localhost port binding is blocked');
|
||||
expect(blocked).toContain('not that the port is occupied');
|
||||
|
||||
const occupied = __testInternals__.formatExplicitPortUnavailableError(34567, {
|
||||
available: false,
|
||||
code: 'EADDRINUSE',
|
||||
message: 'address already in use',
|
||||
}).message;
|
||||
|
||||
expect(occupied).toBe('[browse] Port 34567 (from BROWSE_PORT env) is in use');
|
||||
});
|
||||
|
||||
test('random port diagnostic calls out sandbox-style bind denial', () => {
|
||||
const message = __testInternals__.formatRandomPortUnavailableError([
|
||||
{ port: 11001, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
{ port: 12002, result: { available: false, code: 'EPERM', message: 'operation not permitted' } },
|
||||
]).message;
|
||||
|
||||
expect(message).toContain('Cannot bind localhost ports after 2 attempts');
|
||||
expect(message).toContain('Last error: 12002 (EPERM: operation not permitted)');
|
||||
expect(message).toContain('not that every sampled port is occupied');
|
||||
expect(message).toContain('set BROWSE_PORT to an approved port');
|
||||
});
|
||||
|
||||
test('random port diagnostic preserves old busy-port meaning when all attempts are occupied', () => {
|
||||
const message = __testInternals__.formatRandomPortUnavailableError([
|
||||
{ port: 11001, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
{ port: 12002, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
]).message;
|
||||
|
||||
expect(message).toContain('No available port after 5 attempts');
|
||||
expect(message).toContain('every sampled port was already in use');
|
||||
});
|
||||
|
||||
test('isPortAvailable returns true for a free port', async () => {
|
||||
// Use the same isPortAvailable logic from server.ts
|
||||
|
||||
Reference in New Issue
Block a user