fix: Windows support — Node.js server fallback for Playwright (#255)

* fix: Windows support — Node.js server fallback for Playwright

Setup hangs on Windows 11 because Bun's child_process can't handle
Playwright's --remote-debugging-pipe (fd 3/4 pipe handles). Fall back
to Node.js on Windows for both the setup verification and server
runtime. macOS/Linux completely unaffected — all Windows code behind
IS_WINDOWS / process.platform === 'win32' guards.

Based on community PR #194 by @sozairali. Fixed sed -i portability
(perl -pi -e) in build-node-server.sh for macOS compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: cross-platform path handling for Windows compatibility

Replace hardcoded '/tmp' and 'dir + "/"' path checks with
platform-aware constants from new platform.ts module. On macOS/Linux
this evaluates identically ('/tmp', '/'); on Windows it uses
os.tmpdir() and path.sep. Zero behavior change on Unix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add tests for Windows polyfill, platform constants, and Node server resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: Windows support in README + CHANGELOG (v0.9.1.1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.9.3.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-20 12:22:11 -07:00
committed by GitHub
parent 6a6b2b0766
commit d7c732b282
20 changed files with 430 additions and 29 deletions
+1 -1
View File
@@ -358,7 +358,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
-s <sel> --selector Scope to CSS selector
-D --diff Unified diff against previous snapshot (first call stores baseline)
-a --annotate Annotated screenshot with red overlay boxes and ref labels
-o <path> --output Output path for annotated screenshot (default: /tmp/browse-annotated.png)
-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)
```
+1 -1
View File
@@ -486,7 +486,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
-s <sel> --selector Scope to CSS selector
-D --diff Unified diff against previous snapshot (first call stores baseline)
-a --annotate Annotated screenshot with red overlay boxes and ref labels
-o <path> --output Output path for annotated screenshot (default: /tmp/browse-annotated.png)
-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)
```
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [0.9.3.0] - 2026-03-20 — Windows Support
### Fixed
- **gstack now works on Windows 11.** Setup no longer hangs when verifying Playwright, and the browse server automatically falls back to Node.js to work around a Bun pipe-handling bug on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). Just make sure Node.js is installed alongside Bun. macOS and Linux are completely unaffected.
- **Path handling works on Windows.** All hardcoded `/tmp` paths and Unix-style path separators now use platform-aware equivalents via a new `platform.ts` module. Path traversal protection works correctly with Windows backslash separators.
### Added
- **Bun API polyfill for Node.js.** When the browse server runs under Node.js on Windows, a compatibility layer provides `Bun.serve()`, `Bun.spawn()`, `Bun.spawnSync()`, and `Bun.sleep()` equivalents. Fully tested.
- **Node server build script.** `browse/scripts/build-node-server.sh` transpiles the server for Node.js, stubs `bun:sqlite`, and injects the polyfill — all automated during `bun run build`.
## [0.9.2.0] - 2026-03-20 — Gemini CLI E2E Tests
### Added
+3 -1
View File
@@ -42,7 +42,7 @@ Expect first useful run in under 5 minutes on any repo with tests already set up
## Install — takes 30 seconds
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Git](https://git-scm.com/), [Bun](https://bun.sh/) v1.0+, [Node.js](https://nodejs.org/) (Windows only)
### Step 1: Install on your machine
@@ -238,6 +238,8 @@ Data is stored in [Supabase](https://supabase.com) (open source Firebase alterna
**Stale install?** Run `/gstack-upgrade` — or set `auto_upgrade: true` in `~/.gstack/config.yaml`
**Windows users:** gstack works on Windows 11 via Git Bash or WSL. Node.js is required in addition to Bun — Bun has a known bug with Playwright's pipe transport on Windows ([bun#4253](https://github.com/oven-sh/bun/issues/4253)). The browse server automatically falls back to Node.js. Make sure both `bun` and `node` are on your PATH.
**Claude says it can't see the skills?** Make sure your project's `CLAUDE.md` has a gstack section. Add this:
```
+1 -1
View File
@@ -492,7 +492,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
-s <sel> --selector Scope to CSS selector
-D --diff Unified diff against previous snapshot (first call stores baseline)
-a --annotate Annotated screenshot with red overlay boxes and ref labels
-o <path> --output Output path for annotated screenshot (default: /tmp/browse-annotated.png)
-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)
```
+1 -1
View File
@@ -1 +1 @@
0.9.2.0
0.9.3.0
+1 -1
View File
@@ -364,7 +364,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
-s <sel> --selector Scope to CSS selector
-D --diff Unified diff against previous snapshot (first call stores baseline)
-a --annotate Annotated screenshot with red overlay boxes and ref labels
-o <path> --output Output path for annotated screenshot (default: /tmp/browse-annotated.png)
-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)
```
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build a Node.js-compatible server bundle for Windows.
#
# On Windows, Bun can't launch or connect to Playwright's Chromium
# (oven-sh/bun#4253, #9911). This script produces a server bundle
# that runs under Node.js with Bun API polyfills.
set -e
GSTACK_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
SRC_DIR="$GSTACK_DIR/browse/src"
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)
bun build "$SRC_DIR/server.ts" \
--target=node \
--outfile "$DIST_DIR/server-node.mjs" \
--external playwright \
--external playwright-core \
--external diff \
--external "bun:sqlite"
# Step 2: Post-process
# Replace import.meta.dir with a resolvable reference
perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs"
# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows)
perl -pi -e 's|import { Database } from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs"
# Step 3: Create the final file with polyfill header injected after the first line
{
head -1 "$DIST_DIR/server-node.mjs"
echo '// ── Windows Node.js compatibility (auto-generated) ──'
echo 'import { fileURLToPath as _ftp } from "node:url";'
echo 'import { dirname as _dn } from "node:path";'
echo 'const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";'
echo '{ const _r = createRequire(import.meta.url); _r("./bun-polyfill.cjs"); }'
echo '// ── end compatibility ──'
tail -n +2 "$DIST_DIR/server-node.mjs"
} > "$DIST_DIR/server-node.tmp.mjs"
mv "$DIST_DIR/server-node.tmp.mjs" "$DIST_DIR/server-node.mjs"
# Step 4: Copy polyfill to dist/
cp "$SRC_DIR/bun-polyfill.cjs" "$DIST_DIR/bun-polyfill.cjs"
echo "Node server bundle ready: $DIST_DIR/server-node.mjs"
+109
View File
@@ -0,0 +1,109 @@
/**
* Bun API polyfill for Node.js Windows compatibility layer.
*
* On Windows, Bun can't launch or connect to Playwright's Chromium
* (oven-sh/bun#4253, #9911). The browse server falls back to running
* under Node.js with this polyfill providing Bun API equivalents.
*
* Loaded via --require before the transpiled server bundle.
*/
'use strict';
const http = require('http');
const { spawnSync, spawn } = require('child_process');
globalThis.Bun = {
serve(options) {
const { port, hostname = '127.0.0.1', fetch } = options;
const server = http.createServer(async (nodeReq, nodeRes) => {
try {
const url = `http://${hostname}:${port}${nodeReq.url}`;
const headers = new Headers();
for (const [key, val] of Object.entries(nodeReq.headers)) {
if (val) headers.set(key, Array.isArray(val) ? val[0] : val);
}
let body = null;
if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') {
body = await new Promise((resolve) => {
const chunks = [];
nodeReq.on('data', (chunk) => chunks.push(chunk));
nodeReq.on('end', () => resolve(Buffer.concat(chunks)));
});
}
const webReq = new Request(url, {
method: nodeReq.method,
headers,
body,
});
const webRes = await fetch(webReq);
nodeRes.statusCode = webRes.status;
webRes.headers.forEach((val, key) => {
nodeRes.setHeader(key, val);
});
const resBody = await webRes.arrayBuffer();
nodeRes.end(Buffer.from(resBody));
} catch (err) {
nodeRes.statusCode = 500;
nodeRes.end(JSON.stringify({ error: err.message }));
}
});
server.listen(port, hostname);
return {
stop() { server.close(); },
port,
hostname,
};
},
spawnSync(cmd, options = {}) {
const [command, ...args] = cmd;
const result = spawnSync(command, args, {
stdio: [
options.stdin || 'pipe',
options.stdout === 'pipe' ? 'pipe' : 'ignore',
options.stderr === 'pipe' ? 'pipe' : 'ignore',
],
timeout: options.timeout,
env: options.env,
cwd: options.cwd,
});
return {
exitCode: result.status,
stdout: result.stdout || Buffer.from(''),
stderr: result.stderr || Buffer.from(''),
};
},
spawn(cmd, options = {}) {
const [command, ...args] = cmd;
const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
const proc = spawn(command, args, {
stdio,
env: options.env,
cwd: options.cwd,
});
return {
pid: proc.pid,
stdout: proc.stdout,
stderr: proc.stderr,
stdin: proc.stdin,
unref() { proc.unref(); },
kill(signal) { proc.kill(signal); },
};
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
};
+38 -4
View File
@@ -14,7 +14,8 @@ import * as path from 'path';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
const config = resolveConfig();
const MAX_START_WAIT = 8000; // 8 seconds to start
const IS_WINDOWS = process.platform === 'win32';
const MAX_START_WAIT = IS_WINDOWS ? 15000 : 8000; // Node+Chromium takes longer on Windows
export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
@@ -26,7 +27,9 @@ export function resolveServerScript(
}
// Dev mode: cli.ts runs directly from browse/src
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
// On macOS/Linux, import.meta.dir starts with /
// On Windows, it starts with a drive letter (e.g., C:\...)
if (!metaDir.includes('$bunfs')) {
const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) {
return direct;
@@ -48,6 +51,31 @@ export function resolveServerScript(
const SERVER_SCRIPT = resolveServerScript();
/**
* On Windows, resolve the Node.js-compatible server bundle.
* Falls back to null if not found (server will use Bun instead).
*/
export function resolveNodeServerScript(
metaDir: string = import.meta.dir,
execPath: string = process.execPath
): string | null {
// Dev mode
if (!metaDir.includes('$bunfs')) {
const distScript = path.resolve(metaDir, '..', 'dist', 'server-node.mjs');
if (fs.existsSync(distScript)) return distScript;
}
// Compiled binary: browse/dist/browse → browse/dist/server-node.mjs
if (execPath) {
const adjacent = path.resolve(path.dirname(execPath), 'server-node.mjs');
if (fs.existsSync(adjacent)) return adjacent;
}
return null;
}
const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
interface ServerState {
pid: number;
port: number;
@@ -139,8 +167,14 @@ async function startServer(): Promise<ServerState> {
// Clean up stale state file
try { fs.unlinkSync(config.stateFile); } catch {}
// Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
// Start server as detached background process.
// On Windows, Bun can't launch/connect to Playwright's Chromium (oven-sh/bun#4253, #9911).
// Fall back to running the server under Node.js with Bun API polyfills.
const useNode = IS_WINDOWS && NODE_SERVER_SCRIPT;
const serverCmd = useNode
? ['node', NODE_SERVER_SCRIPT]
: ['bun', 'run', SERVER_SCRIPT];
const proc = Bun.spawn(serverCmd, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
+6 -5
View File
@@ -10,13 +10,14 @@ import { validateNavigationUrl } from './url-validation';
import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
@@ -88,7 +89,7 @@ export async function handleMetaCommand(
case 'screenshot': {
// Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
const page = bm.getPage();
let outputPath = '/tmp/browse-screenshot.png';
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
@@ -147,7 +148,7 @@ export async function handleMetaCommand(
case 'pdf': {
const page = bm.getPage();
const pdfPath = args[0] || '/tmp/browse-page.pdf';
const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
validateOutputPath(pdfPath);
await page.pdf({ path: pdfPath, format: 'A4' });
return `PDF saved: ${pdfPath}`;
@@ -155,7 +156,7 @@ export async function handleMetaCommand(
case 'responsive': {
const page = bm.getPage();
const prefix = args[0] || '/tmp/browse-responsive';
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
validateOutputPath(prefix);
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
+17
View File
@@ -0,0 +1,17 @@
/**
* Cross-platform constants for gstack browse.
*
* On macOS/Linux: TEMP_DIR = '/tmp', path.sep = '/' identical to hardcoded values.
* On Windows: TEMP_DIR = os.tmpdir(), path.sep = '\\' correct Windows behavior.
*/
import * as os from 'os';
import * as path from 'path';
export const IS_WINDOWS = process.platform === 'win32';
export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp';
/** Check if resolvedPath is within dir, using platform-aware separators. */
export function isPathWithin(resolvedPath: string, dir: string): boolean {
return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep);
}
+3 -2
View File
@@ -10,6 +10,7 @@ import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
function hasAwait(code: string): boolean {
@@ -36,12 +37,12 @@ function wrapForEvaluate(code: string): string {
}
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
export function validateReadPath(filePath: string): void {
if (path.isAbsolute(filePath)) {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
+5 -4
View File
@@ -20,6 +20,7 @@
import type { Page, Locator } from 'playwright';
import type { BrowserManager, RefEntry } from './browser-manager';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
// Roles considered "interactive" for the -i flag
const INTERACTIVE_ROLES = new Set([
@@ -61,7 +62,7 @@ export const SNAPSHOT_FLAGS: Array<{
{ short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '<sel>', optionKey: 'selector' },
{ short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' },
{ 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: /tmp/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
{ 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)', optionKey: 'cursorInteractive' },
];
@@ -308,11 +309,11 @@ export async function handleSnapshot(
// ─── Annotated screenshot (-a) ────────────────────────────
if (opts.annotate) {
const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png';
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
// Validate output path (consistent with screenshot/pdf/responsive)
const resolvedPath = require('path').resolve(screenshotPath);
const safeDirs = ['/tmp', process.cwd()];
if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) {
const safeDirs = [TEMP_DIR, process.cwd()];
if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
}
try {
+3 -2
View File
@@ -10,6 +10,7 @@ import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
import { validateNavigationUrl } from './url-validation';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
export async function handleWriteCommand(
command: string,
@@ -277,9 +278,9 @@ export async function handleWriteCommand(
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
// Path validation — prevent reading arbitrary files
if (path.isAbsolute(filePath)) {
const safeDirs = ['/tmp', process.cwd()];
const safeDirs = [TEMP_DIR, process.cwd()];
const resolved = path.resolve(filePath);
if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) {
if (!safeDirs.some(dir => isPathWithin(resolved, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, test, expect, afterAll } from 'bun:test';
import * as path from 'path';
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
describe('bun-polyfill', () => {
// We test the polyfill by requiring it in a subprocess under Node.js
// since it's designed for Node, not Bun.
test('Bun.sleep resolves after delay', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
(async () => {
const start = Date.now();
await Bun.sleep(50);
const elapsed = Date.now() - start;
console.log(elapsed >= 40 ? 'OK' : 'TOO_FAST');
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('OK');
expect(result.exitCode).toBe(0);
});
test('Bun.spawnSync runs a command and returns stdout', () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
console.log(r.stdout.toString().trim());
console.log('exit:' + r.exitCode);
`], { stdout: 'pipe', stderr: 'pipe' });
const lines = result.stdout.toString().trim().split('\n');
expect(lines[0]).toBe('hello');
expect(lines[1]).toBe('exit:0');
});
test('Bun.spawn launches a process with pid', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
console.log(typeof p.unref === 'function' ? 'HAS_UNREF' : 'NO_UNREF');
`], { stdout: 'pipe', stderr: 'pipe' });
const lines = result.stdout.toString().trim().split('\n');
expect(lines[0]).toBe('HAS_PID');
expect(lines[1]).toBe('HAS_KILL');
expect(lines[2]).toBe('HAS_UNREF');
});
test('Bun.serve creates an HTTP server that responds', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${polyfillPath}');
const server = Bun.serve({
port: 0, // Note: polyfill uses port directly, so we pick one
hostname: '127.0.0.1',
fetch(req) {
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
},
});
// The polyfill doesn't support port 0, so we test the object shape
console.log(typeof server.stop === 'function' ? 'HAS_STOP' : 'NO_STOP');
console.log(typeof server.port === 'number' ? 'HAS_PORT' : 'NO_PORT');
server.stop();
`], { stdout: 'pipe', stderr: 'pipe' });
const lines = result.stdout.toString().trim().split('\n');
expect(lines[0]).toBe('HAS_STOP');
expect(lines[1]).toBe('HAS_PORT');
});
});
+30
View File
@@ -197,6 +197,36 @@ describe('resolveServerScript', () => {
});
});
describe('resolveNodeServerScript', () => {
const { resolveNodeServerScript } = require('../src/cli');
test('finds server-node.mjs in dist from dev mode', () => {
const srcDir = path.resolve(__dirname, '../src');
const distFile = path.resolve(srcDir, '..', 'dist', 'server-node.mjs');
const fs = require('fs');
// Only test if the file exists (it may not be built yet)
if (fs.existsSync(distFile)) {
const result = resolveNodeServerScript(srcDir, '');
expect(result).toBe(distFile);
}
});
test('returns null when server-node.mjs does not exist', () => {
const result = resolveNodeServerScript('/nonexistent/$bunfs', '/nonexistent/browse');
expect(result).toBeNull();
});
test('finds server-node.mjs adjacent to compiled binary', () => {
const distDir = path.resolve(__dirname, '../dist');
const distFile = path.join(distDir, 'server-node.mjs');
const fs = require('fs');
if (fs.existsSync(distFile)) {
const result = resolveNodeServerScript('/$bunfs/something', path.join(distDir, 'browse'));
expect(result).toBe(distFile);
}
});
});
describe('version mismatch detection', () => {
test('detects when versions differ', () => {
const stateVersion = 'abc123';
+37
View File
@@ -0,0 +1,37 @@
import { describe, test, expect } from 'bun:test';
import { TEMP_DIR, isPathWithin, IS_WINDOWS } from '../src/platform';
describe('platform constants', () => {
test('TEMP_DIR is /tmp on non-Windows', () => {
if (!IS_WINDOWS) {
expect(TEMP_DIR).toBe('/tmp');
}
});
test('IS_WINDOWS reflects process.platform', () => {
expect(IS_WINDOWS).toBe(process.platform === 'win32');
});
});
describe('isPathWithin', () => {
test('path inside directory returns true', () => {
expect(isPathWithin('/tmp/foo', '/tmp')).toBe(true);
});
test('path outside directory returns false', () => {
expect(isPathWithin('/etc/foo', '/tmp')).toBe(false);
});
test('exact match returns true', () => {
expect(isPathWithin('/tmp', '/tmp')).toBe(true);
});
test('partial prefix does not match (path traversal)', () => {
// /tmp-evil should NOT match /tmp
expect(isPathWithin('/tmp-evil/foo', '/tmp')).toBe(false);
});
test('nested path returns true', () => {
expect(isPathWithin('/tmp/a/b/c', '/tmp')).toBe(true);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@
"browse": "./browse/dist/browse"
},
"scripts": {
"build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build || true",
"build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build || true",
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
"dev": "bun run browse/src/cli.ts",
"server": "bun run browse/src/server.ts",
+41 -5
View File
@@ -12,6 +12,11 @@ GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
BROWSE_BIN="$GSTACK_DIR/browse/dist/browse"
IS_WINDOWS=0
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
esac
# ─── Parse --host flag ─────────────────────────────────────────
HOST="claude"
while [ $# -gt 0 ]; do
@@ -44,10 +49,19 @@ elif [ "$HOST" = "codex" ]; then
fi
ensure_playwright_browser() {
(
cd "$GSTACK_DIR"
bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();'
) >/dev/null 2>&1
if [ "$IS_WINDOWS" -eq 1 ]; then
# On Windows, Bun can't launch Chromium due to broken pipe handling
# (oven-sh/bun#4253). Use Node.js to verify Chromium works instead.
(
cd "$GSTACK_DIR"
node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null
)
else
(
cd "$GSTACK_DIR"
bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();'
) >/dev/null 2>&1
fi
}
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
@@ -87,10 +101,32 @@ if ! ensure_playwright_browser; then
cd "$GSTACK_DIR"
bunx playwright install chromium
)
if [ "$IS_WINDOWS" -eq 1 ]; then
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
# Ensure playwright is importable by Node from the gstack directory.
if ! command -v node >/dev/null 2>&1; then
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
echo " Install Node.js: https://nodejs.org/" >&2
exit 1
fi
echo "Windows detected — verifying Node.js can load Playwright..."
(
cd "$GSTACK_DIR"
# Bun's node_modules already has playwright; verify Node can require it
node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright
)
fi
fi
if ! ensure_playwright_browser; then
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
if [ "$IS_WINDOWS" -eq 1 ]; then
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
else
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
fi
exit 1
fi