mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 01:45:29 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/gbrain-code-smell-audit
# Conflicts: # CHANGELOG.md # browse/test/dual-listener.test.ts # browse/test/fixtures/security-bench-haiku-responses.json # browse/test/sidebar-tabs.test.ts # browse/test/sidebar-ux.test.ts # browse/test/terminal-agent.test.ts # claude/SKILL.md.tmpl # scripts/gen-skill-docs.ts # scripts/proactive-suggestions.json # spec/SKILL.md # test/gen-skill-docs.test.ts # test/host-config.test.ts
This commit is contained in:
@@ -42,9 +42,14 @@ beforeAll(async () => {
|
||||
// The test needs to start a server. Let's use the existing server infrastructure.
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
// We need a running browse server for HTTP tests.
|
||||
|
||||
@@ -3,6 +3,9 @@ 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');
|
||||
// Forward slashes so the path survives interpolation into a JS string literal
|
||||
// on Windows, which is the platform this polyfill exists for.
|
||||
const requirePath = polyfillPath.replace(/\\/g, '/');
|
||||
|
||||
describe('bun-polyfill', () => {
|
||||
// We test the polyfill by requiring it in a subprocess under Node.js
|
||||
@@ -10,7 +13,7 @@ describe('bun-polyfill', () => {
|
||||
|
||||
test('Bun.sleep resolves after delay', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${polyfillPath}');
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
const start = Date.now();
|
||||
await Bun.sleep(50);
|
||||
@@ -24,7 +27,7 @@ describe('bun-polyfill', () => {
|
||||
|
||||
test('Bun.spawnSync runs a command and returns stdout', () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${polyfillPath}');
|
||||
require('${requirePath}');
|
||||
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
|
||||
console.log(r.stdout.toString().trim());
|
||||
console.log('exit:' + r.exitCode);
|
||||
@@ -36,7 +39,7 @@ describe('bun-polyfill', () => {
|
||||
|
||||
test('Bun.spawn launches a process with pid', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${polyfillPath}');
|
||||
require('${requirePath}');
|
||||
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');
|
||||
@@ -48,9 +51,179 @@ describe('bun-polyfill', () => {
|
||||
expect(lines[2]).toBe('HAS_UNREF');
|
||||
});
|
||||
|
||||
// Bun.spawn parity: `proc.exited` is a Promise resolving to the exit code.
|
||||
// The DPAPI helper and isBrowserRunning both `await proc.exited`; without
|
||||
// it the awaits resolve immediately to `undefined` and the caller reads
|
||||
// stdout before the child has produced it — surfacing as a silent failure.
|
||||
test('Bun.spawn exposes proc.exited that resolves to the exit code', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
const p = Bun.spawn(['node', '-e', 'process.exit(0)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
console.log(typeof p.exited === 'object' && typeof p.exited.then === 'function' ? 'IS_PROMISE' : 'NOT_PROMISE');
|
||||
console.log('exit:' + await p.exited);
|
||||
})();
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const lines = result.stdout.toString().trim().split('\n');
|
||||
expect(lines[0]).toBe('IS_PROMISE');
|
||||
expect(lines[1]).toBe('exit:0');
|
||||
});
|
||||
|
||||
test('Bun.spawn proc.exited reflects non-zero exit codes', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
const p = Bun.spawn(['node', '-e', 'process.exit(3)'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
console.log('exit:' + await p.exited);
|
||||
})();
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('exit:3');
|
||||
});
|
||||
|
||||
test('Bun.spawn proc.exited resolves before reading stdout (no race)', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
// Real-world pattern: write to stdout, then exit. Awaiting proc.exited
|
||||
// before reading must guarantee the bytes are flushed.
|
||||
const p = Bun.spawn(['node', '-e', 'process.stdout.write("ready"); process.exit(0)'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
const code = await p.exited;
|
||||
const out = await new Response(p.stdout).text();
|
||||
console.log(out + ':' + code);
|
||||
})();
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('ready:0');
|
||||
});
|
||||
|
||||
// Spawn-failure case: Node emits 'error' but not 'exit' when the binary
|
||||
// is missing, so listening only for 'exit' hangs `await proc.exited`
|
||||
// forever. The lifecycle promise must resolve on either event.
|
||||
test('Bun.spawn proc.exited resolves on spawn failure (missing binary)', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
const p = Bun.spawn(['this-binary-does-not-exist-zzz-' + Date.now()], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
const code = await Promise.race([
|
||||
p.exited,
|
||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
|
||||
]).catch(() => 'TIMEOUT');
|
||||
console.log('exit:' + code);
|
||||
})();
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
// Anything other than 'TIMEOUT' (and ideally a non-zero number) means the
|
||||
// lifecycle promise resolved on the spawn error.
|
||||
const out = result.stdout.toString().trim();
|
||||
expect(out).not.toBe('exit:TIMEOUT');
|
||||
expect(out).toMatch(/^exit:\d+$/);
|
||||
});
|
||||
|
||||
// GSTACK_SPAWN_MAX_BUFFER caps the drain so a runaway child can't OOM the
|
||||
// server. Past the cap, the pipe keeps flowing (child doesn't block) but
|
||||
// further bytes are dropped. Set a small cap, write more than that, assert
|
||||
// the captured stdout equals the cap and the child exits cleanly.
|
||||
test('Bun.spawn caps buffered output at GSTACK_SPAWN_MAX_BUFFER', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
process.env.GSTACK_SPAWN_MAX_BUFFER = '${1024}';
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
// Child writes 10 KB; cap is 1 KB; drained output should be exactly 1 KB
|
||||
// and exit should still resolve cleanly (child not back-pressured to death).
|
||||
const p = Bun.spawn(
|
||||
['node', '-e', 'process.stdout.write("y".repeat(10 * 1024)); process.exit(0)'],
|
||||
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
const code = await Promise.race([
|
||||
p.exited,
|
||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
|
||||
]).catch(() => 'TIMEOUT');
|
||||
const out = await new Response(p.stdout).text();
|
||||
console.log(out.length + ':' + code);
|
||||
})();
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('1024:0');
|
||||
});
|
||||
|
||||
// Regression for the pipe-blocking case: if the child writes more than the
|
||||
// OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the
|
||||
// child blocks in write() and `exit` never fires. 1 MB is well past every
|
||||
// OS pipe buffer size. Pre-fix this test hangs forever; post-fix it returns
|
||||
// in <500ms. Bun's default per-test timeout is 5s — generous here.
|
||||
test('Bun.spawn drains large stdout so proc.exited still resolves', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${requirePath}');
|
||||
(async () => {
|
||||
const ONE_MB = 1024 * 1024;
|
||||
// Exit in the write callback, not straight after write(): on modern
|
||||
// Node a pipe write past the OS buffer is async, and process.exit()
|
||||
// right after write() truncates at ~64 KB even with a live reader.
|
||||
// The callback only fires once the full MB is flushed — which still
|
||||
// requires the parent to drain, so the regression (no eager drain →
|
||||
// child blocks → timeout) is still caught.
|
||||
const p = Bun.spawn(
|
||||
['node', '-e', 'process.stdout.write("x".repeat(' + ONE_MB + '), () => process.exit(0))'],
|
||||
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
const code = await Promise.race([
|
||||
p.exited,
|
||||
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 10000))
|
||||
]).catch(e => 'TIMEOUT');
|
||||
const out = await new Response(p.stdout).text();
|
||||
console.log(out.length + ':' + code);
|
||||
})().catch((e) => { console.log('THREW:' + e.message); });
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('1048576:0');
|
||||
}, 15000);
|
||||
|
||||
// windowsHide is the one option where Node's default is the opposite of
|
||||
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
|
||||
// in translation makes every spawned child pop a window on Windows, which
|
||||
// is the platform this whole file exists for. Both shims are covered.
|
||||
test('Bun.spawn defaults windowsHide to true', () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
const cp = require('child_process');
|
||||
const orig = cp.spawn;
|
||||
let seen;
|
||||
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||
require('${requirePath}');
|
||||
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
console.log('windowsHide:' + seen.windowsHide);
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
||||
});
|
||||
|
||||
test('Bun.spawnSync defaults windowsHide to true', () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
const cp = require('child_process');
|
||||
const orig = cp.spawnSync;
|
||||
let seen;
|
||||
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||
require('${requirePath}');
|
||||
Bun.spawnSync(['node', '-e', '']);
|
||||
console.log('windowsHide:' + seen.windowsHide);
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
|
||||
});
|
||||
|
||||
test('an explicit windowsHide:false is honored', () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
const cp = require('child_process');
|
||||
const orig = cp.spawn;
|
||||
let seen;
|
||||
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
|
||||
require('${requirePath}');
|
||||
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
|
||||
console.log('windowsHide:' + seen.windowsHide);
|
||||
`], { stdout: 'pipe', stderr: 'pipe' });
|
||||
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
|
||||
});
|
||||
|
||||
test('Bun.serve creates an HTTP server that responds', async () => {
|
||||
const result = Bun.spawnSync(['node', '-e', `
|
||||
require('${polyfillPath}');
|
||||
require('${requirePath}');
|
||||
const server = Bun.serve({
|
||||
port: 0, // Note: polyfill uses port directly, so we pick one
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
@@ -18,7 +18,7 @@ import { withCdpSession, getOrCreateCdpSession } from '../src/cdp-bridge';
|
||||
// browse/test/server-sanitize-surrogates.test.ts: read source files
|
||||
// directly, assert an invariant on their contents.
|
||||
|
||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
||||
const SRC_DIR = path.resolve(import.meta.path, '..', '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
const out: Array<{ file: string; content: string }> = [];
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { acquireServerLock } from '../src/cli';
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function captureErrors<T>(fn: () => T): { result: T; messages: string[] } {
|
||||
const original = console.error;
|
||||
const messages: string[] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(' '));
|
||||
};
|
||||
try {
|
||||
return { result: fn(), messages };
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
}
|
||||
|
||||
describe('browse CLI server lock diagnostics (#1084)', () => {
|
||||
test('logs non-EEXIST open failures instead of reporting phantom lock contention', () => {
|
||||
withTempDir((dir) => {
|
||||
const lockPath = path.join(dir, 'missing-parent', 'browse.json.lock');
|
||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(messages.join('\n')).toContain('unexpected ENOENT while opening');
|
||||
expect(messages.join('\n')).toContain(lockPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null silently when a live process holds the lock', () => {
|
||||
withTempDir((dir) => {
|
||||
const lockPath = path.join(dir, 'browse.json.lock');
|
||||
fs.writeFileSync(lockPath, `${process.pid}\n`);
|
||||
|
||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('logs holder PID read failures with code and lock path', () => {
|
||||
withTempDir((dir) => {
|
||||
const lockPath = path.join(dir, 'browse.json.lock');
|
||||
fs.mkdirSync(lockPath);
|
||||
|
||||
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(messages.join('\n')).toContain('unexpected EISDIR while reading holder PID from');
|
||||
expect(messages.join('\n')).toContain(lockPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('removes stale lock and reacquires it', () => {
|
||||
withTempDir((dir) => {
|
||||
const lockPath = path.join(dir, 'browse.json.lock');
|
||||
fs.writeFileSync(lockPath, 'not-a-pid\n');
|
||||
|
||||
const release = acquireServerLock(lockPath);
|
||||
|
||||
expect(release).toBeFunction();
|
||||
expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid));
|
||||
release?.();
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Coverage for #1846 — `browse` CLI must not report "Server failed to start
|
||||
* within Ns" when the detached daemon actually came up healthy a moment later.
|
||||
*
|
||||
* The spawned server is `detached: true` + `.unref()`'d, so it keeps booting
|
||||
* independently of the CLI's poll loop. On a loaded machine (the issue repro is
|
||||
* Windows under load) the loop's budget can elapse in the gap between its last
|
||||
* health tick and the daemon becoming ready — the very next `browse status`
|
||||
* then shows a healthy, listening server. #1732 only widened the budget; the
|
||||
* throw site itself still fired on timeout regardless of real health.
|
||||
*
|
||||
* Two invariants are defended here:
|
||||
* 1. `startServer` does a final readState()+isServerHealthy() re-check before
|
||||
* the timeout throw (structural — removes the false negative at any budget).
|
||||
* 2. The startup budget is env-overridable via BROWSE_START_TIMEOUT, matching
|
||||
* the BROWSE_* tunable convention (BROWSE_PORT, BROWSE_IDLE_TIMEOUT, ...).
|
||||
*
|
||||
* (1) is a static source invariant (live spawn cycles belong in the e2e tier);
|
||||
* (2) is exercised behaviorally against the exported pure helper.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveStartTimeout } from '../src/cli';
|
||||
|
||||
const CLI = path.join(import.meta.dir, '..', 'src', 'cli.ts');
|
||||
const read = (): string => fs.readFileSync(CLI, 'utf-8');
|
||||
|
||||
describe('#1846 startServer false-negative on a late-healthy detached daemon', () => {
|
||||
test('a final health re-check sits between the poll loop and the timeout throw', () => {
|
||||
const src = read();
|
||||
const throwIdx = src.indexOf('Server failed to start within');
|
||||
expect(throwIdx).toBeGreaterThan(-1);
|
||||
|
||||
// The startServer poll loop ends at its `await Bun.sleep(100)`; the final
|
||||
// re-check must live AFTER that loop and BEFORE the timeout throw.
|
||||
const loopEnd = src.lastIndexOf('await Bun.sleep(100)', throwIdx);
|
||||
expect(loopEnd).toBeGreaterThan(-1);
|
||||
const between = src.slice(loopEnd, throwIdx);
|
||||
|
||||
// It must re-read state and re-probe health, then be able to return — i.e.
|
||||
// a genuine recovery path, not just a comment.
|
||||
expect(between).toContain('readState()');
|
||||
expect(between).toMatch(/isServerHealthy\([^)]*\)/);
|
||||
expect(between).toMatch(/return\s+\w+;/);
|
||||
});
|
||||
|
||||
test('the re-check returns the recovered state rather than swallowing it', () => {
|
||||
const src = read();
|
||||
// Guard against a refactor that probes health but forgets to return the
|
||||
// state (which would re-introduce the false negative).
|
||||
expect(src).toMatch(/if\s*\([^)]*await\s+isServerHealthy\([^)]*\)\)\s*\{\s*return\s+\w+;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1846 BROWSE_START_TIMEOUT env override (resolveStartTimeout)', () => {
|
||||
const platformDefault = resolveStartTimeout({} as NodeJS.ProcessEnv);
|
||||
|
||||
test('platform default is a positive millisecond budget when unset', () => {
|
||||
expect(platformDefault).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('honors a positive BROWSE_START_TIMEOUT override', () => {
|
||||
expect(resolveStartTimeout({ BROWSE_START_TIMEOUT: '42000' } as NodeJS.ProcessEnv)).toBe(42000);
|
||||
});
|
||||
|
||||
test('falls back to the platform default for non-positive / unparseable values', () => {
|
||||
for (const bad of ['0', '-5', 'abc', '', ' ']) {
|
||||
expect(resolveStartTimeout({ BROWSE_START_TIMEOUT: bad } as NodeJS.ProcessEnv)).toBe(platformDefault);
|
||||
}
|
||||
});
|
||||
|
||||
test('MAX_START_WAIT is wired through resolveStartTimeout (no stray hardcoded constant)', () => {
|
||||
const src = read();
|
||||
expect(src).toMatch(/const\s+MAX_START_WAIT\s*=\s*resolveStartTimeout\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import * as path from 'path';
|
||||
// 3-8s each). These tripwires defend the load-bearing invariants:
|
||||
// opt-in by default, signal handlers wired, crash-loop guard, env knobs.
|
||||
|
||||
const CLI_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts');
|
||||
const CLI_TS = path.resolve(import.meta.path, '..', '..', 'src', 'cli.ts');
|
||||
|
||||
describe('CLI outer supervisor (v1.44+)', () => {
|
||||
test('1. supervisor is opt-in via --supervise flag or BROWSE_SUPERVISE env', () => {
|
||||
|
||||
@@ -126,11 +126,14 @@ beforeAll(async () => {
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Force kill browser instead of graceful close (avoids hang)
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
// bm.close() can hang — just let process exit handle it
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
// ─── Navigation ─────────────────────────────────────────────────
|
||||
@@ -913,7 +916,10 @@ describe('CLI lifecycle', () => {
|
||||
cliEnv.BROWSE_STATE_FILE = stateFile;
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
||||
const proc = spawn('bun', ['run', cliPath, 'status'], {
|
||||
timeout: 15000,
|
||||
// Must exceed the CLI's startup budget (resolveStartTimeout, 15s
|
||||
// non-CI POSIX) or a slow cold boot under full-suite load gets the
|
||||
// child killed at the exact moment the CLI would have succeeded.
|
||||
timeout: 18000,
|
||||
env: cliEnv,
|
||||
});
|
||||
let stdout = '';
|
||||
@@ -2315,6 +2321,19 @@ describe('load-html', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('load-html rejects .svg files', async () => {
|
||||
const svgPath = path.join(tmpDir, `load-html-test-${Date.now()}.svg`);
|
||||
fs.writeFileSync(svgPath, '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>');
|
||||
try {
|
||||
await handleWriteCommand('load-html', [svgPath], bm);
|
||||
expect(true).toBe(false);
|
||||
} catch (err: any) {
|
||||
expect(err.message).toMatch(/does not appear to be HTML/);
|
||||
} finally {
|
||||
try { fs.unlinkSync(svgPath); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test('load-html rejects file outside safe dirs', async () => {
|
||||
try {
|
||||
await handleWriteCommand('load-html', ['/etc/passwd.html'], bm);
|
||||
|
||||
@@ -69,10 +69,15 @@ beforeAll(async () => {
|
||||
await handleWriteCommand('goto', [boardUrl], bm);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { server.stop(); } catch {}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
// ─── DOM Structure ──────────────────────────────────────────────
|
||||
|
||||
@@ -124,6 +124,41 @@ describe('config', () => {
|
||||
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('leaves .gitignore alone when git already ignores .gstack/ globally', () => {
|
||||
const { spawnSync } = require('child_process');
|
||||
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-global-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
// Set up a real git repo
|
||||
spawnSync('git', ['init', '-q'], { cwd: tmpDir });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
||||
|
||||
// Write a global excludes file that ignores .gstack/
|
||||
const excludesFile = path.join(tmpDir, 'global-gitignore');
|
||||
fs.writeFileSync(excludesFile, '.gstack/\n');
|
||||
spawnSync('git', ['config', 'core.excludesFile', excludesFile], { cwd: tmpDir });
|
||||
|
||||
// .gitignore exists but does NOT contain .gstack/
|
||||
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
|
||||
spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir });
|
||||
spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
|
||||
|
||||
// Verify git knows .gstack/ is ignored
|
||||
const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir });
|
||||
expect(check.status).toBe(0);
|
||||
|
||||
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
|
||||
ensureStateDir(config);
|
||||
|
||||
// .gitignore must NOT have been modified
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
|
||||
expect(content).toBe('node_modules/\n');
|
||||
expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true);
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteSlug', () => {
|
||||
|
||||
@@ -470,9 +470,14 @@ describe('Hidden element stripping', () => {
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test
|
||||
// runs all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
test('detects CSS-hidden elements on injection-hidden page', async () => {
|
||||
|
||||
@@ -224,9 +224,8 @@ describe('/command tunnel command allowlist', () => {
|
||||
'return handleCommand(body, tokenInfo)'
|
||||
);
|
||||
expect(commandBlock).toContain("surface === 'tunnel'");
|
||||
// v1.63.0.0 made the allowlist args-aware (canDispatchOverTunnel gained a
|
||||
// second param for --out denial); this pin was stale from then until the
|
||||
// free suite got a CI job.
|
||||
// Args-aware since the --out (disk write) tunnel ban: the dispatch gate
|
||||
// takes both the command and its args.
|
||||
expect(commandBlock).toContain('canDispatchOverTunnel(body?.command, body?.args)');
|
||||
expect(commandBlock).toContain('disallowed_command');
|
||||
expect(commandBlock).toContain('is not allowed over the tunnel surface');
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Sender authorization for privileged extension messages.
|
||||
*
|
||||
* A content script runs in web-page context and can be influenced by page
|
||||
* content; a foreign extension is not us. Neither may read or spend the
|
||||
* browse server's auth token or port through background.js's message
|
||||
* surface. PR #1822 (@punksterlabs) found getPort handing the token to any
|
||||
* caller that passed the type allowlist; this suite pins the reimplemented
|
||||
* gate BEHAVIORALLY — it drives the real background.js onMessage listener
|
||||
* under a chrome stub with four sender shapes (own extension page, own
|
||||
* content script, foreign extension, url-less) and asserts denied responses
|
||||
* are { error: 'unauthorized' } with no token/port fields at all.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const EXT_DIR = path.join(import.meta.dir, '..', '..', 'extension');
|
||||
const BG_SRC = fs.readFileSync(path.join(EXT_DIR, 'background.js'), 'utf-8');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const senderAuth = require(path.join(EXT_DIR, 'sender-auth.js'));
|
||||
|
||||
// The pinned production id (derivable via browse/scripts/extension-id.ts) —
|
||||
// the policy only compares it against sender.id, so any stable value works.
|
||||
const OWN_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep';
|
||||
const FOREIGN_ID = 'ffffffffffffffffffffffffffffffff';
|
||||
|
||||
// ─── The four sender shapes ─────────────────────────────────────
|
||||
const PAGE_SENDER = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html` };
|
||||
const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', tab: { id: 42 } };
|
||||
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
|
||||
const NO_URL_SENDER = { id: OWN_ID };
|
||||
|
||||
const PRIVILEGED = [
|
||||
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
|
||||
'command', 'sidebar-command', 'getTabState',
|
||||
];
|
||||
// Content-script-originated flows that must keep working.
|
||||
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
|
||||
// Sidepanel-originated, non-privileged (page effects only, no token/port).
|
||||
const PAGE_EFFECT_TYPES = ['sidebarOpened', 'startInspector', 'stopInspector', 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll'];
|
||||
|
||||
const LEAK_FIELDS = ['token', 'authToken', 'port', 'url', 'connected', 'tabs', 'active', 'ok'];
|
||||
|
||||
// ─── Unit: the policy predicate ─────────────────────────────────
|
||||
|
||||
describe('sender-auth policy (unit)', () => {
|
||||
test('own extension page is allowed for every privileged type', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
|
||||
}
|
||||
expect(senderAuth.isExtensionPageSender(PAGE_SENDER, OWN_ID)).toBe(true);
|
||||
});
|
||||
|
||||
test('own popup page is allowed (any own-extension page path)', () => {
|
||||
const popup = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/popup.html` };
|
||||
expect(senderAuth.denialFor('getPort', popup, OWN_ID)).toBeNull();
|
||||
});
|
||||
|
||||
test('own content script (sender.tab + page URL) is denied for every privileged type', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
const denial = senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID);
|
||||
expect(denial).toEqual({ error: 'unauthorized' });
|
||||
expect(Object.keys(denial)).toEqual(['error']);
|
||||
}
|
||||
});
|
||||
|
||||
test('foreign extension id is denied for every privileged type', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
expect(senderAuth.denialFor(type, FOREIGN_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing sender.url is denied (no provenance)', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
expect(senderAuth.denialFor(type, NO_URL_SENDER, OWN_ID)).toEqual({ error: 'unauthorized' });
|
||||
}
|
||||
expect(senderAuth.denialFor('getToken', undefined, OWN_ID)).toEqual({ error: 'unauthorized' });
|
||||
});
|
||||
|
||||
test('own extension page opened inside a TAB is denied (conservative: sender.tab wins)', () => {
|
||||
const pageInTab = { id: OWN_ID, url: `chrome-extension://${OWN_ID}/sidepanel.html`, tab: { id: 7 } };
|
||||
expect(senderAuth.denialFor('getToken', pageInTab, OWN_ID)).toEqual({ error: 'unauthorized' });
|
||||
});
|
||||
|
||||
test('non-privileged types are never gated here — content-script flows stay reachable', () => {
|
||||
for (const type of [...CONTENT_SCRIPT_TYPES, ...PAGE_EFFECT_TYPES]) {
|
||||
expect(senderAuth.denialFor(type, CONTENT_SCRIPT_SENDER, OWN_ID)).toBeNull();
|
||||
expect(senderAuth.denialFor(type, PAGE_SENDER, OWN_ID)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavioral: the real background.js listener ────────────────
|
||||
|
||||
type Listener = (msg: unknown, sender: unknown, sendResponse: (r: unknown) => void) => unknown;
|
||||
|
||||
function loadBackground() {
|
||||
const captured: { listener?: Listener } = {};
|
||||
const calls = { storageSet: [] as unknown[], fetch: [] as unknown[] };
|
||||
const never = new Promise(() => {}); // storage.get never settles → startup health polling never starts
|
||||
const chromeStub = {
|
||||
runtime: {
|
||||
id: OWN_ID,
|
||||
onMessage: { addListener: (fn: Listener) => { captured.listener = fn; } },
|
||||
onInstalled: { addListener: () => {} },
|
||||
sendMessage: () => Promise.resolve(),
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: () => never,
|
||||
set: (obj: unknown) => { calls.storageSet.push(obj); return Promise.resolve(); },
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
onActivated: { addListener: () => {} },
|
||||
onCreated: { addListener: () => {} },
|
||||
onRemoved: { addListener: () => {} },
|
||||
onUpdated: { addListener: () => {} },
|
||||
query: (_opts: unknown, cb?: (tabs: unknown[]) => void) => {
|
||||
if (cb) { cb([]); return; }
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sendMessage: () => Promise.resolve(),
|
||||
get: () => {},
|
||||
},
|
||||
action: { setBadgeBackgroundColor: () => {}, setBadgeText: () => {} },
|
||||
scripting: { executeScript: () => Promise.resolve(), insertCSS: () => Promise.resolve() },
|
||||
// no chrome.sidePanel: autoOpenSidePanel exits immediately (no retry timers)
|
||||
};
|
||||
const fetchSpy = (...args: unknown[]) => {
|
||||
calls.fetch.push(args);
|
||||
return Promise.reject(new Error('no network in tests'));
|
||||
};
|
||||
// background.js is a classic (non-module) service worker script — evaluate
|
||||
// it with its globals injected. importScripts is satisfied by passing the
|
||||
// already-required sender-auth module under the global name it registers.
|
||||
const run = new Function('chrome', 'importScripts', 'gstackSenderAuth', 'fetch', BG_SRC);
|
||||
run(chromeStub, () => {}, senderAuth, fetchSpy);
|
||||
if (!captured.listener) throw new Error('background.js did not register an onMessage listener');
|
||||
return { listener: captured.listener, calls };
|
||||
}
|
||||
|
||||
function dispatch(listener: Listener, msg: unknown, sender: unknown) {
|
||||
const result = { responded: false, response: undefined as Record<string, unknown> | undefined };
|
||||
listener(msg, sender, (resp: unknown) => {
|
||||
result.responded = true;
|
||||
result.response = resp as Record<string, unknown>;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Denied senders get { error: 'unauthorized' } and nothing else — or no
|
||||
// response at all (the pre-existing foreign-sender early return). Either
|
||||
// way: never a token, port, or tab-state field.
|
||||
function expectDenied(result: ReturnType<typeof dispatch>) {
|
||||
if (result.responded) {
|
||||
expect(result.response).toEqual({ error: 'unauthorized' });
|
||||
expect(Object.keys(result.response!)).toEqual(['error']);
|
||||
}
|
||||
const resp = result.response ?? {};
|
||||
for (const leak of LEAK_FIELDS) {
|
||||
expect(resp[leak]).toBeUndefined();
|
||||
}
|
||||
}
|
||||
|
||||
describe('background.js onMessage listener (behavioral)', () => {
|
||||
const { listener, calls } = loadBackground();
|
||||
|
||||
test('own sidepanel page: getPort responds with port/connected/token fields, no error', () => {
|
||||
const r = dispatch(listener, { type: 'getPort' }, PAGE_SENDER);
|
||||
expect(r.responded).toBe(true);
|
||||
expect('port' in r.response!).toBe(true);
|
||||
expect('connected' in r.response!).toBe(true);
|
||||
// The sidepanel's tryConnect reads resp.token — the field must exist for
|
||||
// extension pages (value is null until the token bootstrap completes).
|
||||
expect('token' in r.response!).toBe(true);
|
||||
expect(r.response!.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('own sidepanel page: getToken responds with a token field', () => {
|
||||
const r = dispatch(listener, { type: 'getToken' }, PAGE_SENDER);
|
||||
expect(r.responded).toBe(true);
|
||||
expect('token' in r.response!).toBe(true);
|
||||
expect(r.response!.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('own content script: every privileged type is denied with no token/port fields', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
const r = dispatch(listener, { type }, CONTENT_SCRIPT_SENDER);
|
||||
expect(r.responded).toBe(true); // the gate answers, it does not go silent
|
||||
expectDenied(r);
|
||||
}
|
||||
});
|
||||
|
||||
test('foreign extension: every privileged type yields no token/port fields', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
expectDenied(dispatch(listener, { type }, FOREIGN_SENDER));
|
||||
}
|
||||
});
|
||||
|
||||
test('missing sender.url: every privileged type is denied', () => {
|
||||
for (const type of PRIVILEGED) {
|
||||
const r = dispatch(listener, { type }, NO_URL_SENDER);
|
||||
expect(r.responded).toBe(true);
|
||||
expectDenied(r);
|
||||
}
|
||||
});
|
||||
|
||||
test('denied setPort never persists the attacker port', () => {
|
||||
const before = calls.storageSet.length;
|
||||
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);
|
||||
expectDenied(r);
|
||||
expect(calls.storageSet.length).toBe(before);
|
||||
});
|
||||
|
||||
test('denied command never reaches the network and fails at the gate, not the handler', () => {
|
||||
const before = calls.fetch.length;
|
||||
const r = dispatch(listener, { type: 'command', command: 'goto', args: ['https://evil.example'] }, CONTENT_SCRIPT_SENDER);
|
||||
// 'unauthorized' proves the gate fired; the handler's own failure mode is
|
||||
// 'Not connected to browse server'.
|
||||
expect(r.response).toEqual({ error: 'unauthorized' });
|
||||
expect(calls.fetch.length).toBe(before);
|
||||
});
|
||||
|
||||
test('content script can still run the inspector flow (elementPicked → ok)', async () => {
|
||||
const r = dispatch(
|
||||
listener,
|
||||
{ type: 'elementPicked', selector: '#hero', tagName: 'div', classes: [], id: null, dimensions: { width: 1, height: 1 } },
|
||||
CONTENT_SCRIPT_SENDER,
|
||||
);
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
expect(r.response).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('content script can still request openSidePanel (not rejected as unauthorized)', () => {
|
||||
const r = dispatch(listener, { type: 'openSidePanel' }, CONTENT_SCRIPT_SENDER);
|
||||
// chrome.sidePanel is absent in the stub so the handler is a no-op — the
|
||||
// load-bearing assertion is that the gate did not deny it.
|
||||
expect(r.response?.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sidepanel getTabState still works (terminal pane tab sync)', async () => {
|
||||
const r = dispatch(listener, { type: 'getTabState' }, PAGE_SENDER);
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
expect(r.responded).toBe(true);
|
||||
expect(r.response).toEqual({ active: null, tabs: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Wiring tripwire ────────────────────────────────────────────
|
||||
// The behavioral suite injects senderAuth directly, so pin that the real
|
||||
// worker actually loads it: importScripts of the helper file plus a
|
||||
// denialFor call in the listener. A refactor that drops either fails here.
|
||||
|
||||
describe('background.js ↔ sender-auth.js wiring', () => {
|
||||
test('background.js importScripts sender-auth.js (classic worker load path)', () => {
|
||||
expect(BG_SRC).toContain("importScripts('sender-auth.js')");
|
||||
});
|
||||
|
||||
test('background.js consults gstackSenderAuth.denialFor in the message listener', () => {
|
||||
expect(BG_SRC).toContain('gstackSenderAuth.denialFor(msg.type, sender, chrome.runtime.id)');
|
||||
});
|
||||
|
||||
test('manifest keeps a classic (non-module) service worker — importScripts requires it', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8'));
|
||||
expect(manifest.background.service_worker).toBe('background.js');
|
||||
expect(manifest.background.type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,26 @@ describe('restrictDirectoryPermissions', () => {
|
||||
fs.mkdirSync(d);
|
||||
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
|
||||
});
|
||||
|
||||
test('on Windows, the directory stays usable by the calling process', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const d = path.join(tmpDir, 'still-usable');
|
||||
fs.mkdirSync(d);
|
||||
fs.writeFileSync(path.join(d, 'before'), 'x');
|
||||
|
||||
restrictDirectoryPermissions(d);
|
||||
|
||||
// Regression: an unqualified username passed to icacls can resolve to
|
||||
// the machine SID rather than the user account. Combined with
|
||||
// /inheritance:r that leaves a directory whose only ACE matches nobody,
|
||||
// so the process that just "secured" it can no longer enumerate or
|
||||
// write to it. icacls still reports success, so a not-toThrow assertion
|
||||
// sails straight past it — hence these access checks.
|
||||
expect(() => fs.readdirSync(d)).not.toThrow();
|
||||
expect(fs.readdirSync(d)).toContain('before');
|
||||
expect(() => fs.writeFileSync(path.join(d, 'after'), 'y')).not.toThrow();
|
||||
expect(fs.readFileSync(path.join(d, 'after'), 'utf8')).toBe('y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSecureFile', () => {
|
||||
@@ -138,6 +158,16 @@ describe('mkdirSecure', () => {
|
||||
expect(() => mkdirSecure(d)).not.toThrow();
|
||||
});
|
||||
|
||||
test('on Windows, the created directory stays usable by the caller', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// The state-dir path that broke: mkdirSecure() creates .gstack/, hardens
|
||||
// it, and the very next thing the daemon does is write a lockfile inside.
|
||||
const d = path.join(tmpDir, 'state', '.gstack');
|
||||
mkdirSecure(d);
|
||||
expect(() => fs.writeFileSync(path.join(d, 'browse.json.lock'), '1')).not.toThrow();
|
||||
expect(fs.readdirSync(d)).toContain('browse.json.lock');
|
||||
});
|
||||
|
||||
test('recursive behavior: creates intermediate directories', () => {
|
||||
const d = path.join(tmpDir, 'a', 'b', 'c');
|
||||
mkdirSecure(d);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Regression test for `browse fill` on change-only validators.
|
||||
*
|
||||
* Playwright's Locator.fill() dispatches an `input` event but not `change`.
|
||||
* Frameworks that validate on `change` (AngularJS ng-change, debounced
|
||||
* strength/match checks — e.g. cPanel's Jupiter theme "Add FTP Account"
|
||||
* password-match check) never see the update: the DOM value is correct but
|
||||
* the framework's own validator still reports a mismatch.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { startTestServer } from './test-server';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||
|
||||
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||
|
||||
let testServer: ReturnType<typeof startTestServer>;
|
||||
let bm: BrowserManager;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testServer = startTestServer(0);
|
||||
baseUrl = testServer.url;
|
||||
bm = new BrowserManager();
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, so race it at 3s and abandon; the child is reaped at exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
describe('fill dispatches change event', () => {
|
||||
test('a change-only validator sees the filled value', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/change-only-validator.html'], bm);
|
||||
await handleWriteCommand('fill', ['#password', 'hello123'], bm);
|
||||
await handleWriteCommand('fill', ['#password2', 'hello123'], bm);
|
||||
|
||||
const status = await bm.getPage().locator('#match-status').textContent();
|
||||
expect(status).toBe('match');
|
||||
});
|
||||
|
||||
test('a change-only validator still catches a real mismatch', async () => {
|
||||
await handleWriteCommand('goto', [baseUrl + '/change-only-validator.html'], bm);
|
||||
await handleWriteCommand('fill', ['#password', 'hello123'], bm);
|
||||
await handleWriteCommand('fill', ['#password2', 'different'], bm);
|
||||
|
||||
const status = await bm.getPage().locator('#match-status').textContent();
|
||||
expect(status).toBe('no-match');
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test Page - Change-Only Validator</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Change-Only Validator</h1>
|
||||
|
||||
<!--
|
||||
Minimal repro of AngularJS ng-change / debounced cross-field validators
|
||||
(e.g. cPanel's Jupiter theme "Add FTP Account" password-match check):
|
||||
the listener only reacts to `change`, never `input`. A page like this
|
||||
silently "loses" a Playwright-style value-set-without-a-change-event.
|
||||
-->
|
||||
<input type="password" id="password" name="password">
|
||||
<input type="password" id="password2" name="password2">
|
||||
<div id="match-status">unknown</div>
|
||||
|
||||
<script>
|
||||
function checkMatch() {
|
||||
var a = document.getElementById('password').value;
|
||||
var b = document.getElementById('password2').value;
|
||||
document.getElementById('match-status').textContent =
|
||||
a && a === b ? 'match' : 'no-match';
|
||||
}
|
||||
document.getElementById('password').addEventListener('change', checkMatch);
|
||||
document.getElementById('password2').addEventListener('change', checkMatch);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,6 +42,14 @@ beforeEach(() => {
|
||||
const binDir = join(gstackDir, 'bin');
|
||||
mkdirSync(binDir);
|
||||
symlinkSync(join(import.meta.dir, '..', '..', 'bin', 'gstack-config'), join(binDir, 'gstack-config'));
|
||||
// v1.63+: the script sources bin/gstack-egress-lib.sh unconditionally
|
||||
// (receipted fetch helpers). A real install always has it beside
|
||||
// gstack-config; without this link every test failed at the source line —
|
||||
// masked until the suite-truncation fix because the runner died first.
|
||||
symlinkSync(
|
||||
join(import.meta.dir, '..', '..', 'bin', 'gstack-egress-lib.sh'),
|
||||
join(binDir, 'gstack-egress-lib.sh'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -26,9 +26,14 @@ beforeAll(async () => {
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
|
||||
@@ -172,8 +177,15 @@ describe('handoff edge cases', () => {
|
||||
// Each handoff test creates its own BrowserManager since handoff swaps the browser.
|
||||
// These tests run sequentially (one browser at a time) to avoid resource issues.
|
||||
|
||||
// Headed-mode launch is broken on current macOS (the rebrand invalidates the
|
||||
// Chrome-for-Testing bundle signature and XProtect kills the relaunch —
|
||||
// #2242, #2554, #2138). These three integration tests drive a real headed
|
||||
// handoff and fail ~5s in on any darwin box. They stay ENABLED on Linux CI.
|
||||
// Un-skip when the browse-daemon lifecycle wave lands the signature fix.
|
||||
const HEADED_BROKEN_ON_DARWIN = process.platform === 'darwin';
|
||||
|
||||
describe('handoff integration', () => {
|
||||
test('full handoff: cookies preserved, headed mode active, commands work', async () => {
|
||||
test.skipIf(HEADED_BROKEN_ON_DARWIN)('full handoff: cookies preserved, headed mode active, commands work', async () => {
|
||||
const hbm = new BrowserManager();
|
||||
await hbm.launch();
|
||||
|
||||
@@ -206,7 +218,7 @@ describe('handoff integration', () => {
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
test('multi-tab handoff preserves all tabs', async () => {
|
||||
test.skipIf(HEADED_BROKEN_ON_DARWIN)('multi-tab handoff preserves all tabs', async () => {
|
||||
const hbm = new BrowserManager();
|
||||
await hbm.launch();
|
||||
|
||||
@@ -223,7 +235,7 @@ describe('handoff integration', () => {
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
test('handoff meta command joins args as message', async () => {
|
||||
test.skipIf(HEADED_BROKEN_ON_DARWIN)('handoff meta command joins args as message', async () => {
|
||||
const hbm = new BrowserManager();
|
||||
await hbm.launch();
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { isProcessAlive } from '../src/error-handling';
|
||||
import { spawnTerminalAgent } from '../src/terminal-agent-control';
|
||||
|
||||
// REGRESSION TEST for the Windows terminal-agent leak.
|
||||
//
|
||||
// Symptom (reported on Windows 11, 48GB box under a heavy parallel build):
|
||||
// a console window popped to the foreground every 60 seconds, and orphaned
|
||||
// `bun run terminal-agent.ts` processes accumulated at one per minute until
|
||||
// the machine ran out of committable memory.
|
||||
//
|
||||
// Root cause was a three-bug chain, each of which this file pins:
|
||||
//
|
||||
// 1. `isProcessAlive` shelled out to `tasklist` on Windows with a 3s
|
||||
// timeout. A Bun.spawnSync that hits its timeout STILL RETURNS, carrying
|
||||
// partial stdout — so the `.includes()` PID match came back false and a
|
||||
// LIVE agent was reported dead. Measured tasklist latency was 700-1700ms
|
||||
// idle, and far worse under memory pressure, so the timeout was reachable
|
||||
// in ordinary use.
|
||||
// 2. That false negative made `killAgentByRecord` skip the kill (it
|
||||
// validates liveness first) while the watchdog respawned anyway —
|
||||
// leaking the survivor. Each orphan added memory pressure, slowing the
|
||||
// next tasklist, producing the next false negative. Self-reinforcing.
|
||||
// 3. Neither the tasklist probe nor the agent spawn passed `windowsHide`,
|
||||
// so every tick allocated a visible console and stole focus.
|
||||
//
|
||||
// The guard-window arithmetic bug that let this run unbounded instead of
|
||||
// tripping the crash-loop guard is pinned separately, in test 6.
|
||||
|
||||
const SRC_DIR = path.resolve(import.meta.dir, '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
return fs
|
||||
.readdirSync(SRC_DIR)
|
||||
.filter((e) => e.endsWith('.ts'))
|
||||
.map((e) => ({ file: e, content: fs.readFileSync(path.join(SRC_DIR, e), 'utf-8') }));
|
||||
}
|
||||
|
||||
/** Strip line and block comments so static greps only see real code. */
|
||||
function stripComments(src: string): string {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
describe('process liveness probe (Windows terminal-agent leak)', () => {
|
||||
test('1. isProcessAlive reports the current process alive', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
test('2. isProcessAlive reports an unused PID dead', () => {
|
||||
// Below Linux PID_MAX_LIMIT, far above any realistic Windows/macOS PID.
|
||||
expect(isProcessAlive(2147483646)).toBe(false);
|
||||
});
|
||||
|
||||
test('3. isProcessAlive spawns NO subprocess', () => {
|
||||
// The heart of the bug: a liveness probe that forks is slow enough to
|
||||
// time out, and a timed-out probe silently answers "dead". Signal 0
|
||||
// cannot time out because it never leaves the process.
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
const origSpawnSync = (Bun as any).spawnSync;
|
||||
const spawns: string[] = [];
|
||||
(Bun as any).spawn = (...args: any[]) => { spawns.push(`spawn:${JSON.stringify(args[0])}`); return origSpawn(...args); };
|
||||
(Bun as any).spawnSync = (...args: any[]) => { spawns.push(`spawnSync:${JSON.stringify(args[0])}`); return origSpawnSync(...args); };
|
||||
try {
|
||||
isProcessAlive(process.pid);
|
||||
isProcessAlive(2147483646);
|
||||
expect(spawns).toEqual([]);
|
||||
} finally {
|
||||
(Bun as any).spawn = origSpawn;
|
||||
(Bun as any).spawnSync = origSpawnSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('4. no source file probes liveness via tasklist', () => {
|
||||
// Static tripwire: re-introducing a tasklist-based existence check
|
||||
// anywhere in src/ resurrects the false-negative class.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
const code = stripComments(content);
|
||||
// `PID eq` is the existence-probe form specifically. Other tasklist
|
||||
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.
|
||||
if (/tasklist/.test(code) && /PID eq/.test(code)) offenders.push(file);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('5. spawnTerminalAgent passes windowsHide so no console is shown', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hide-'));
|
||||
const script = path.join(tmpDir, 'fake-agent.ts');
|
||||
fs.writeFileSync(script, '// no-op\n');
|
||||
const origSpawn = (Bun as any).spawn;
|
||||
let captured: any = null;
|
||||
(Bun as any).spawn = (_cmd: any, opts: any) => {
|
||||
captured = opts;
|
||||
return { pid: 4242, unref() {} };
|
||||
};
|
||||
try {
|
||||
const pid = spawnTerminalAgent({
|
||||
stateFile: path.join(tmpDir, 'state.json'),
|
||||
serverPort: 12345,
|
||||
ownerPid: process.pid,
|
||||
cwd: tmpDir,
|
||||
scriptPath: script,
|
||||
});
|
||||
expect(pid).toBe(4242);
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured.windowsHide).toBe(true);
|
||||
// Owner-PID lifetime tie (#2019): the agent polls this and exits when
|
||||
// its owning browse server dies, so it can't be adopted by PID 1.
|
||||
expect(captured.env.BROWSE_OWNER_PID).toBe(String(process.pid));
|
||||
// Detached background daemon — must not inherit a terminal either.
|
||||
expect(captured.stdio).toEqual(['ignore', 'ignore', 'ignore']);
|
||||
} finally {
|
||||
(Bun as any).spawn = origSpawn;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('6. respawn guard window spans enough ticks for the guard to fire', () => {
|
||||
// The guard was `RESPAWN_GUARD_WINDOW_MS = 60_000` against a 60_000ms
|
||||
// tick, allowing at most ONE respawn in the window — so the
|
||||
// `>= RESPAWN_GUARD_MAX (3)` trip condition was unreachable and a steady
|
||||
// one-per-tick leak never self-limited. Assert the window is derived from
|
||||
// the tick rather than fixed.
|
||||
const src = fs.readFileSync(path.join(SRC_DIR, 'server.ts'), 'utf-8');
|
||||
const match = src.match(/const RESPAWN_GUARD_WINDOW_MS =([\s\S]{0,160}?);/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![1]).toContain('AGENT_WATCHDOG_TICK_MS');
|
||||
|
||||
// Pin the arithmetic itself: at the default tick, three respawns must fit.
|
||||
const tick = 60_000;
|
||||
const guardMax = 3;
|
||||
const windowMs = Math.max(60_000, tick * (guardMax + 2));
|
||||
expect(windowMs).toBeGreaterThanOrEqual(tick * guardMax);
|
||||
});
|
||||
});
|
||||
@@ -56,9 +56,14 @@ describe('defense-in-depth — live Playwright fixture', () => {
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test
|
||||
// runs all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => {
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
// Resolves browse/src/server.ts relative to this test file so the test
|
||||
// works regardless of cwd. import.meta.url is the test file's URL.
|
||||
const serverTsPath = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
import.meta.path,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as path from 'path';
|
||||
// loopback to be live (e2e-tier); these static-grep tripwires pin the
|
||||
// load-bearing protocol invariants.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
|
||||
|
||||
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
|
||||
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
|
||||
|
||||
@@ -157,9 +157,9 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
|
||||
test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => {
|
||||
expect(TERM_JS).toContain('function forceRestart');
|
||||
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
|
||||
// Deliberate close code so the agent's close handler can distinguish an
|
||||
// intentional restart from a dropped connection (codex D8 redesign).
|
||||
expect(fn).toContain("ws.close(4001, 'intentional-restart')");
|
||||
// close() carries an intentional-restart close code so the agent's
|
||||
// close handler can distinguish user restarts from network drops.
|
||||
expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')");
|
||||
expect(fn).toContain('term.dispose()');
|
||||
expect(fn).toContain('STATE.IDLE');
|
||||
expect(fn).toContain('tryAutoConnect()');
|
||||
@@ -225,16 +225,17 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
|
||||
});
|
||||
|
||||
test('Terminal-agent spawn survives', () => {
|
||||
// The inline Bun.spawn of termAgentScript moved into the shared
|
||||
// spawnTerminalAgent helper (terminal-agent-control.ts) so the CLI
|
||||
// cold-start path and the supervisor respawn path share one
|
||||
// identity-tracked spawn. The CLI must still call it.
|
||||
expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'");
|
||||
expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{/);
|
||||
// v1.44 moved the raw Bun.spawn into the shared spawnTerminalAgent
|
||||
// helper (terminal-agent-control.ts) so cli.ts, the supervisor respawn
|
||||
// loop, and the watchdog all share identity-based process control.
|
||||
// cli.ts must still route through that helper.
|
||||
expect(CLI_SRC).toContain('spawnTerminalAgent');
|
||||
const CONTROL_SRC = fs.readFileSync(
|
||||
path.join(import.meta.dir, '../src/terminal-agent-control.ts'), 'utf-8');
|
||||
path.join(import.meta.dir, '../src/terminal-agent-control.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(CONTROL_SRC).toContain('terminal-agent.ts');
|
||||
expect(CONTROL_SRC).toMatch(/spawn\(\['bun',\s*'run',\s*script\]/);
|
||||
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script\]/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+196
-25
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* Tests for sidebar UX invariants that survived the chat-tab rip:
|
||||
* - Browser tab bar HTML/CSS + browser-manager tab sync plumbing
|
||||
* - Inspector message allowlist + CSP fallback basic picker
|
||||
* - Cleanup/screenshot toolbar buttons + deterministic cleanup heuristics
|
||||
* - Welcome page, sidebar auto-open, arrow hint signal chain
|
||||
* - Connection auth race, startup fast-retry, debug visibility
|
||||
* Structural tests for the sidebar's surviving UX surfaces:
|
||||
* - Quick-action toolbar (cleanup via PTY injection, screenshot, cookies)
|
||||
* - CSP fallback basic picker (content.js) + inspector allowlist
|
||||
* - Deterministic cleanup heuristics (write-commands.ts)
|
||||
* - Welcome page + sidebar auto-open + arrow hint signal chain
|
||||
* - Connection/auth race prevention + startup health check
|
||||
* - browser-manager tab tracking + no-focus-steal invariants
|
||||
* - Server shutdown teardown of the terminal-agent
|
||||
*
|
||||
* The chat-queue pipeline (sidebar-agent.ts, /sidebar-command,
|
||||
* /sidebar-chat, chat bubbles) is gone — its tests were pruned with it.
|
||||
* See sidebar-tabs.test.ts for the invariants locking that removal.
|
||||
* History: this file used to also pin the chat-queue architecture
|
||||
* (sidebar-agent.ts, /sidebar-command, /sidebar-chat, /sidebar-tabs,
|
||||
* per-tab chat context, stop button, chat polling, processAgentEvent,
|
||||
* pickSidebarModel). That entire path was deliberately ripped in PR #1216
|
||||
* (v1.14.0.0) when the interactive claude PTY (terminal-agent.ts) proved
|
||||
* strictly more capable — see docs/designs/SIDEBAR_MESSAGE_FLOW.md. The
|
||||
* stale blocks kept "passing" only because a teardown bug made `bun test`
|
||||
* exit 0 before reporting; once that was fixed (PR #2172) they surfaced as
|
||||
* failures and were removed. The rip itself is pinned as absence tests in
|
||||
* browse/test/sidebar-tabs.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
@@ -17,8 +26,6 @@ import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// ─── Browser tab bar ────────────────────────────────────────────
|
||||
|
||||
describe('browser tab bar (sidepanel.html)', () => {
|
||||
const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8');
|
||||
|
||||
@@ -48,8 +55,6 @@ describe('sidebar→browser tab switch', () => {
|
||||
|
||||
describe('browser→sidebar tab sync', () => {
|
||||
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
|
||||
test('syncActiveTabByUrl method exists on BrowserManager', () => {
|
||||
expect(bmSrc).toContain('syncActiveTabByUrl(activeUrl: string)');
|
||||
@@ -89,12 +94,16 @@ describe('browser→sidebar tab sync', () => {
|
||||
expect(fn).toContain('this.pages.size <= 1');
|
||||
});
|
||||
|
||||
// NOTE: the /sidebar-tabs + /sidebar-command server consumers of
|
||||
// syncActiveTabByUrl and the sidepanel chat-tab handlers were removed
|
||||
// with the chat-queue rip (PR #1216). The BrowserManager primitives above
|
||||
// survive (tab tracking feeds active-tab.json for the PTY claude).
|
||||
|
||||
test('background.js listens for chrome.tabs.onActivated', () => {
|
||||
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
|
||||
expect(bgSrc).toContain('chrome.tabs.onActivated.addListener');
|
||||
expect(bgSrc).toContain('browserTabActivated');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('browser tab bar (sidepanel.css)', () => {
|
||||
@@ -197,13 +206,14 @@ describe('CSP fallback basic picker', () => {
|
||||
expect(contentSrc).toContain('getBoundingClientRect()');
|
||||
});
|
||||
|
||||
test('content.js contains CSSOM iteration tolerating cross-origin sheets', () => {
|
||||
test('content.js contains CSSOM iteration guarded against cross-origin sheets', () => {
|
||||
expect(contentSrc).toContain('document.styleSheets');
|
||||
expect(contentSrc).toContain('cssRules');
|
||||
// Cross-origin sheets throw DOMException on cssRules access — the
|
||||
// iteration swallows exactly that (typed catch), nothing broader.
|
||||
expect(contentSrc).toContain('same-origin only');
|
||||
expect(contentSrc).toContain('instanceof DOMException');
|
||||
// Cross-origin stylesheets throw DOMException on cssRules access. The
|
||||
// iteration must swallow exactly that (typed catch, not a bare catch {}
|
||||
// — see the slop-scan philosophy in CLAUDE.md).
|
||||
expect(contentSrc).toContain('(same-origin only)');
|
||||
expect(contentSrc).toMatch(/catch \(e\) \{ if \(!\(e instanceof DOMException\)\) throw e; \}/);
|
||||
});
|
||||
|
||||
test('content.js saves and restores outline on elements', () => {
|
||||
@@ -260,6 +270,24 @@ describe('cleanup and screenshot buttons', () => {
|
||||
expect(html).toContain('quick-actions');
|
||||
});
|
||||
|
||||
test('cleanup button injects smart prompt into the live PTY (not just deterministic selectors)', () => {
|
||||
// Cleanup pipes a prompt into the running claude PTY via
|
||||
// gstackInjectToTerminal (the chat-queue POST to /sidebar-command was
|
||||
// ripped in PR #1216 — the live REPL is the only execution surface).
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
expect(cleanupFn).toContain('gstackInjectToTerminal');
|
||||
expect(cleanupFn).toContain('cleanupPrompt');
|
||||
// Should include both deterministic first pass AND agent snapshot analysis
|
||||
expect(cleanupFn).toContain('cleanup --all');
|
||||
expect(cleanupFn).toContain('snapshot -i');
|
||||
// Should instruct claude to keep site branding
|
||||
expect(cleanupFn).toContain('Keep the site');
|
||||
expect(cleanupFn).toContain('header/masthead');
|
||||
});
|
||||
|
||||
test('sidepanel.js screenshot handler POSTs to /command with screenshot', () => {
|
||||
expect(js).toContain("command: 'screenshot'");
|
||||
});
|
||||
@@ -408,9 +436,9 @@ describe('chat toolbar buttons disabled state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No focus stealing (switchTab bringToFront) ─────────────────
|
||||
// ─── Focus stealing prevention ──────────────────────────────────
|
||||
|
||||
describe('no focus stealing (switchTab bringToFront)', () => {
|
||||
describe('tab switching does not steal focus', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
|
||||
|
||||
@@ -438,6 +466,17 @@ describe('LLM-based cleanup (smart agent cleanup)', () => {
|
||||
const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
|
||||
const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8');
|
||||
|
||||
test('cleanup button does not bypass the agent with a direct /command POST', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
// The smart cleanup goes through the claude PTY, never a raw
|
||||
// deterministic /command fetch. (The PTY-injection wiring itself is
|
||||
// pinned in sidebar-tabs.test.ts.)
|
||||
expect(cleanupFn).not.toMatch(/fetch.*\/command['"]/);
|
||||
});
|
||||
|
||||
test('cleanup prompt includes deterministic first pass', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
@@ -447,6 +486,64 @@ describe('LLM-based cleanup (smart agent cleanup)', () => {
|
||||
expect(cleanupFn).toContain('cleanup --all');
|
||||
});
|
||||
|
||||
test('cleanup prompt instructs agent to snapshot and analyze', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
// Agent should take a snapshot to see what deterministic pass missed
|
||||
expect(cleanupFn).toContain('snapshot -i');
|
||||
// Agent should analyze what remains
|
||||
expect(cleanupFn).toContain('identify any remaining');
|
||||
});
|
||||
|
||||
test('cleanup prompt lists specific clutter categories for agent', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
// Should guide the agent on what to look for
|
||||
expect(cleanupFn).toContain('cookie/consent banners');
|
||||
expect(cleanupFn).toContain('newsletter popups');
|
||||
expect(cleanupFn).toContain('login walls');
|
||||
expect(cleanupFn).toContain('video autoplay');
|
||||
expect(cleanupFn).toContain('sidebar');
|
||||
expect(cleanupFn).toContain('share');
|
||||
expect(cleanupFn).toContain('floating chat');
|
||||
});
|
||||
|
||||
test('cleanup prompt instructs agent to preserve site identity', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
// Must keep the site looking like itself
|
||||
expect(cleanupFn).toContain('Keep the site');
|
||||
expect(cleanupFn).toContain('header/masthead');
|
||||
expect(cleanupFn).toContain('headline');
|
||||
expect(cleanupFn).toContain('article body');
|
||||
expect(cleanupFn).toContain('byline');
|
||||
});
|
||||
|
||||
test('cleanup prompt instructs agent to unlock scrolling', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
expect(cleanupFn).toContain('unlock scrolling');
|
||||
expect(cleanupFn).toContain('scroll-locked');
|
||||
});
|
||||
|
||||
test('cleanup prompt instructs agent to use $B eval for removal', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
js.indexOf('async function runScreenshot('),
|
||||
);
|
||||
// Agent should use $B eval to hide elements via JavaScript
|
||||
expect(cleanupFn).toContain('$B eval');
|
||||
expect(cleanupFn).toContain('hide each');
|
||||
});
|
||||
|
||||
test('cleanup removes loading state after short delay (agent is async)', () => {
|
||||
const cleanupFn = js.slice(
|
||||
js.indexOf('async function runCleanup('),
|
||||
@@ -677,13 +774,16 @@ describe('sidebar arrow hint hide flow (4-step signal chain)', () => {
|
||||
test('step 1: sidepanel sends sidebarOpened message on connect', () => {
|
||||
expect(spSrc).toContain("{ type: 'sidebarOpened' }");
|
||||
// Should be in updateConnection, after setConnState('connected').
|
||||
// Window is 1500 chars — the function grew bootstrap-global exports
|
||||
// for sidepanel-terminal.js ahead of the sidebarOpened send.
|
||||
// Window is generous: updateConnection also exposes the PTY bootstrap
|
||||
// globals (gstackServerPort/gstackAuthToken) before the connected branch.
|
||||
const connectFn = spSrc.slice(
|
||||
spSrc.indexOf('function updateConnection('),
|
||||
spSrc.indexOf('function updateConnection(') + 1500,
|
||||
spSrc.indexOf('function updateConnection(') + 2500,
|
||||
);
|
||||
expect(connectFn).toContain('sidebarOpened');
|
||||
const connectedIdx = connectFn.indexOf("setConnState('connected')");
|
||||
const openedIdx = connectFn.indexOf('sidebarOpened');
|
||||
expect(connectedIdx).toBeGreaterThan(0);
|
||||
expect(openedIdx).toBeGreaterThan(connectedIdx);
|
||||
});
|
||||
|
||||
// Step 2: background.js accepts and relays sidebarOpened
|
||||
@@ -798,6 +898,51 @@ describe('sidebar debug visibility when stuck', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BROWSE_NO_AUTOSTART (sidebar headless prevention)', () => {
|
||||
const cliSrc = fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8');
|
||||
const termAgentSrc = fs.readFileSync(path.join(ROOT, 'src', 'terminal-agent.ts'), 'utf-8');
|
||||
|
||||
test('cli.ts checks BROWSE_NO_AUTOSTART before starting a new server', () => {
|
||||
// ensureServer must check this env var BEFORE spawning a server.
|
||||
// (Anchor on the open paren — both functions grew parameters.)
|
||||
const ensureStart = cliSrc.indexOf('async function ensureServer(');
|
||||
const ensureEnd = cliSrc.indexOf('\nasync function ', ensureStart + 1);
|
||||
const ensureServerFn = cliSrc.slice(
|
||||
ensureStart,
|
||||
ensureEnd > ensureStart ? ensureEnd : undefined,
|
||||
);
|
||||
expect(ensureServerFn).toContain('BROWSE_NO_AUTOSTART');
|
||||
expect(ensureServerFn).toContain('process.exit(1)');
|
||||
});
|
||||
|
||||
test('cli.ts shows actionable error message when BROWSE_NO_AUTOSTART blocks', () => {
|
||||
expect(cliSrc).toContain('/open-gstack-browser');
|
||||
expect(cliSrc).toContain('BROWSE_NO_AUTOSTART is set');
|
||||
});
|
||||
|
||||
test('terminal-agent.ts sets BROWSE_NO_AUTOSTART=1 for the claude PTY', () => {
|
||||
// The PTY claude must reuse THIS headed server, never race to spawn
|
||||
// its own. (sidebar-agent.ts, the original setter, was ripped in
|
||||
// PR #1216 — the PTY agent inherited the same env contract.)
|
||||
expect(termAgentSrc).toContain("BROWSE_NO_AUTOSTART: '1'");
|
||||
});
|
||||
|
||||
test('terminal-agent.ts sets BROWSE_PORT for headed server reuse', () => {
|
||||
expect(termAgentSrc).toContain('BROWSE_PORT');
|
||||
});
|
||||
|
||||
test('BROWSE_NO_AUTOSTART check happens before lock acquisition', () => {
|
||||
// The guard must be BEFORE the lock acquisition. If it's after,
|
||||
// we'd acquire a lock and then exit, leaving a stale lock file.
|
||||
const ensureServerStart = cliSrc.indexOf('async function ensureServer(');
|
||||
const noAutoStart = cliSrc.indexOf('BROWSE_NO_AUTOSTART', ensureServerStart);
|
||||
const lockAcquisition = cliSrc.indexOf('Acquire lock', ensureServerStart);
|
||||
expect(noAutoStart).toBeGreaterThan(0);
|
||||
expect(lockAcquisition).toBeGreaterThan(0);
|
||||
expect(noAutoStart).toBeLessThan(lockAcquisition);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idle timeout disabled in headed mode (server.ts) ───────────
|
||||
//
|
||||
// The original 'idle check skips in headed mode' string-grep test was deleted
|
||||
@@ -806,6 +951,32 @@ describe('sidebar debug visibility when stuck', () => {
|
||||
// Behavioral coverage lives in browse/test/server-factory.test.ts under the
|
||||
// 'idle timer + onDisconnect dual-instance fix' describe block, which
|
||||
// exercises the headed/headless/tunnel branches of idleCheckTick directly.
|
||||
// The companion '/sidebar-command resets idle timer' test went with the
|
||||
// chat-queue rip (PR #1216) — /command and /batch reset the timer and are
|
||||
// covered by that factory suite.
|
||||
|
||||
// ─── Shutdown kills the terminal-agent (server.ts) ──────────────
|
||||
|
||||
describe('shutdown cleanup (server.ts)', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('shutdown kills the terminal-agent via identity-based kill (no pkill)', () => {
|
||||
// v1.44+ identity-based teardown: only the PID recorded by THIS
|
||||
// daemon's agent is signaled. The pre-v1.44 `pkill -f terminal-agent`
|
||||
// regex killed sibling gstack sessions on the same host (also pinned
|
||||
// by browse/test/terminal-agent-pid-identity.test.ts).
|
||||
const shutdownFn = serverSrc.slice(
|
||||
serverSrc.indexOf('async function shutdown('),
|
||||
serverSrc.indexOf('async function shutdown(') + 1200,
|
||||
);
|
||||
expect(shutdownFn).toContain('killAgentByRecord');
|
||||
expect(shutdownFn).toContain('readAgentRecord');
|
||||
// No pkill CALL — the word may appear in the explanatory comment, so
|
||||
// match invocation shapes only. The repo-wide reintroduction tripwire
|
||||
// is browse/test/terminal-agent-pid-identity.test.ts.
|
||||
expect(shutdownFn).not.toMatch(/(?:spawnSync|execSync|\$)\(\s*['"`]pkill/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cookie button in sidebar footer ────────────────────────────
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as path from 'path';
|
||||
// explicit unrecoverable signals (401 auth invalid).
|
||||
|
||||
const CLIENT_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
import.meta.path,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as path from 'path';
|
||||
// in the e2e tier.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
|
||||
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
|
||||
|
||||
@@ -16,10 +16,10 @@ import * as path from 'path';
|
||||
// doesn't leak a 60s-zombie claude.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
const SIDEPANEL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
|
||||
import.meta.path, '..', '..', '..', 'extension', 'sidepanel.js',
|
||||
);
|
||||
|
||||
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
|
||||
|
||||
@@ -31,9 +31,14 @@ beforeAll(async () => {
|
||||
await bm.launch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
try { testServer.server.stop(); } catch {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
// Close only this file's own browser — never process.exit(): bun test runs
|
||||
// all files in one process, so a delayed exit kills the whole suite
|
||||
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
|
||||
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
|
||||
// so race it at 3s and abandon; the child is reaped at process exit.
|
||||
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
|
||||
});
|
||||
|
||||
// ─── Snapshot Output ────────────────────────────────────────────
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'path';
|
||||
// in the e2e tier; these static-grep tripwires defend the load-bearing
|
||||
// protocol + correctness properties.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
|
||||
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
|
||||
|
||||
@@ -227,6 +227,45 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
|
||||
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
|
||||
});
|
||||
|
||||
test('upgrade response contains exactly ONE Sec-WebSocket-Protocol header', async () => {
|
||||
// RFC 6455: the server MUST select at most one subprotocol. Bun >= 1.3
|
||||
// auto-echoes the first offered protocol in server.upgrade(), so a
|
||||
// manual echo on top of that produced TWO Sec-WebSocket-Protocol
|
||||
// headers — and strict clients (Chromium, python websockets) reject the
|
||||
// handshake, leaving the sidebar terminal permanently disconnected.
|
||||
//
|
||||
// Headers.get() normalizes duplicates away, so this test handshakes
|
||||
// over a raw socket and counts header lines in the response head.
|
||||
const token = 'dup-proto-token-must-be-at-least-seventeen-chars';
|
||||
await grantToken(token);
|
||||
|
||||
const head = await new Promise<string>((resolve, reject) => {
|
||||
const req =
|
||||
'GET /ws HTTP/1.1\r\n' +
|
||||
`Host: 127.0.0.1:${agentPort}\r\n` +
|
||||
'Connection: Upgrade\r\n' +
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Sec-WebSocket-Version: 13\r\n' +
|
||||
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' +
|
||||
`Sec-WebSocket-Protocol: gstack-pty.${token}\r\n` +
|
||||
'Origin: chrome-extension://test-extension-id\r\n' +
|
||||
'\r\n';
|
||||
let buf = '';
|
||||
const socket = require('net').connect(agentPort, '127.0.0.1', () => socket.write(req));
|
||||
socket.setTimeout(5000, () => { socket.destroy(); reject(new Error('handshake timeout')); });
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString('utf8');
|
||||
const end = buf.indexOf('\r\n\r\n');
|
||||
if (end !== -1) { socket.destroy(); resolve(buf.slice(0, end)); }
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
|
||||
expect(head).toContain('101');
|
||||
const protoLines = head.split('\r\n').filter(l => l.toLowerCase().startsWith('sec-websocket-protocol:'));
|
||||
expect(protoLines).toEqual([`Sec-WebSocket-Protocol: gstack-pty.${token}`]);
|
||||
});
|
||||
|
||||
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
|
||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
||||
headers: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as path from 'path';
|
||||
// (token grant/revoke behavior) already live in
|
||||
// browse/test/terminal-agent-integration.test.ts.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
|
||||
test('1. internalHandler<T> exists with the documented signature', () => {
|
||||
|
||||
@@ -11,8 +11,8 @@ import * as path from 'path';
|
||||
// regressed by a refactor. These tests fail CI if either side stops sending
|
||||
// or stops accepting the protocol frames.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
||||
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const CLIENT_JS = path.resolve(import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
||||
|
||||
describe('terminal-agent WS keepalive (v1.44+)', () => {
|
||||
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
|
||||
const spawned: any[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const proc of spawned.splice(0)) {
|
||||
try { proc.kill?.('SIGKILL'); } catch {}
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('terminal-agent owner lifecycle', () => {
|
||||
test('exits after its owning browse server process exits', async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-owner-'));
|
||||
tempDirs.push(stateDir);
|
||||
const stateFile = path.join(stateDir, 'browse.json');
|
||||
fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-token' }));
|
||||
|
||||
// process.execPath (the running bun) instead of `sleep`: coreutils are
|
||||
// not guaranteed on a bare windows-latest runner, and this test is on the
|
||||
// Windows CI curated list — the owner-orphan leak it pins is a Windows bug.
|
||||
const owner = Bun.spawn(
|
||||
[process.execPath, '-e', 'await Bun.sleep(30000)'],
|
||||
{ stdio: ['ignore', 'ignore', 'ignore'] },
|
||||
);
|
||||
spawned.push(owner);
|
||||
const agent = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_SERVER_PORT: '0',
|
||||
BROWSE_OWNER_PID: String(owner.pid),
|
||||
GSTACK_TERMINAL_OWNER_WATCHDOG_MS: '25',
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
spawned.push(agent);
|
||||
|
||||
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-agent-pid')))).toBe(true);
|
||||
expect(isAlive(agent.pid)).toBe(true);
|
||||
|
||||
owner.kill('SIGTERM');
|
||||
await owner.exited;
|
||||
|
||||
expect(await waitFor(() => !isAlive(agent.pid))).toBe(true);
|
||||
expect(fs.existsSync(path.join(stateDir, 'terminal-agent-pid'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, 'terminal-port'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
// and browse/test/server-sanitize-surrogates.test.ts: read source files
|
||||
// directly, assert an invariant on their contents.
|
||||
|
||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
||||
const SRC_DIR = path.resolve(import.meta.path, '..', '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
const out: Array<{ file: string; content: string }> = [];
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as path from 'path';
|
||||
// - {type:"start"} triggers spawn for eager UX after forceRestart
|
||||
// - maybeSpawnPty helper is the single entry point for both spawn paths
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const AGENT_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
||||
test('1. validTokens is a Map binding token → sessionId', () => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import * as path from 'path';
|
||||
// load-bearing properties: identity-based liveness check (not name match),
|
||||
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
const CONTROL_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent-control.ts');
|
||||
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
|
||||
const CONTROL_TS = path.resolve(import.meta.path, '..', '..', 'src', 'terminal-agent-control.ts');
|
||||
|
||||
describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
test('1. spawnTerminalAgent helper exists with PID return type', () => {
|
||||
@@ -50,7 +50,13 @@ describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
test('4. crash-loop guard with rolling window', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000');
|
||||
// The window MUST be derived from the tick, not a fixed 60_000. It was
|
||||
// hardcoded to 60_000 against a 60_000ms tick, so at most ONE respawn
|
||||
// could ever sit inside the window and the `>= RESPAWN_GUARD_MAX` trip
|
||||
// was unreachable — a steady one-respawn-per-tick leak ran unbounded
|
||||
// instead of self-limiting after 3. Pinning the literal is what let that
|
||||
// ship, so pin the relationship instead.
|
||||
expect(block).toMatch(/RESPAWN_GUARD_WINDOW_MS =[\s\S]{0,200}AGENT_WATCHDOG_TICK_MS/);
|
||||
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
|
||||
expect(block).toContain('respawnHistory');
|
||||
expect(block).toContain('agentRespawnGuardTripped');
|
||||
@@ -72,7 +78,7 @@ describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
|
||||
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
|
||||
const cli = fs.readFileSync(
|
||||
path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts'),
|
||||
path.resolve(import.meta.path, '..', '..', 'src', 'cli.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
|
||||
|
||||
@@ -129,21 +129,26 @@ describe('Source-level guard: terminal-agent', () => {
|
||||
expect(wsHandler).toContain('validTokens.has');
|
||||
});
|
||||
|
||||
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
|
||||
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix, no manual echo', () => {
|
||||
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
||||
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
|
||||
// must strip the prefix before checking validTokens, AND echo the
|
||||
// protocol back in the upgrade response — without the echo, the
|
||||
// browser closes the connection immediately.
|
||||
// must strip the prefix before checking validTokens. The protocol echo
|
||||
// is Bun's job: Bun >= 1.3 auto-echoes the first offered protocol in the
|
||||
// 101 response. A manual echo on top produced a DUPLICATE
|
||||
// Sec-WebSocket-Protocol header, which strict clients (Chromium, python
|
||||
// websockets) reject per RFC 6455 — the sidebar terminal could never
|
||||
// connect. Pin the invariant: no manual echo in the upgrade call.
|
||||
expect(wsHandler).toContain("'gstack-pty.'");
|
||||
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
|
||||
expect(wsHandler).toContain('acceptedProtocol');
|
||||
expect(wsHandler).toContain('sec-websocket-protocol');
|
||||
expect(wsHandler).not.toContain("headers: { 'Sec-WebSocket-Protocol'");
|
||||
});
|
||||
|
||||
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
|
||||
// The whole point of lazy-spawn (codex finding #8) is that the WS
|
||||
// upgrade itself does NOT call spawnClaude. Spawn happens on first
|
||||
// message frame.
|
||||
// upgrade itself does NOT spawn claude. Spawn happens on first
|
||||
// message frame (binary input or the v1.44 explicit `start` frame),
|
||||
// routed through the maybeSpawnPty helper, which is the only caller
|
||||
// of spawnClaude.
|
||||
const upgradeBlock = AGENT_SRC.slice(
|
||||
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
|
||||
AGENT_SRC.indexOf("websocket: {"),
|
||||
@@ -151,11 +156,27 @@ describe('Source-level guard: terminal-agent', () => {
|
||||
// v1.44 renamed spawnClaude -> maybeSpawnPty (explicit `start` frame +
|
||||
// lazy first-byte spawn share one helper). Pin was stale from then until
|
||||
// the free suite got a CI job.
|
||||
expect(upgradeBlock).not.toContain('spawnClaude(');
|
||||
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
||||
// Spawn must be invoked from the message handler (lazy on first byte).
|
||||
// v1.44 routes both spawn triggers (explicit {type:"start"} text frame
|
||||
// and the lazy binary-frame path) through the maybeSpawnPty helper.
|
||||
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
||||
expect(messageHandler).toContain('maybeSpawnPty(');
|
||||
expect(messageHandler).toContain('!session.spawned');
|
||||
// The open() upgrade handler must not spawn — it only creates the
|
||||
// (spawned: false) session record or re-attaches a detached one.
|
||||
const openBlock = AGENT_SRC.slice(
|
||||
AGENT_SRC.indexOf('open(ws)'),
|
||||
AGENT_SRC.indexOf('message(ws, raw)'),
|
||||
);
|
||||
expect(openBlock).not.toContain('spawnClaude(');
|
||||
expect(openBlock).not.toContain('maybeSpawnPty(');
|
||||
// And the helper itself is where spawnClaude actually happens, gated
|
||||
// on session.spawned so it stays a single-shot lazy spawn.
|
||||
const helperBlock = AGENT_SRC.slice(AGENT_SRC.indexOf('function maybeSpawnPty'));
|
||||
expect(helperBlock).toContain('spawnClaude(');
|
||||
expect(helperBlock).toContain('if (session.spawned) return true;');
|
||||
});
|
||||
|
||||
test('process.on uncaughtException + unhandledRejection handlers exist', () => {
|
||||
|
||||
@@ -47,6 +47,28 @@ describe('validateNavigationUrl', () => {
|
||||
await expect(validateNavigationUrl('file://host.example.com/foo.html')).rejects.toThrow(/Unsupported file URL host/i);
|
||||
});
|
||||
|
||||
// The daemon opens its own first tab on about:blank, so blocking it meant a restarted
|
||||
// daemon could never initialise — and `make-pdf setup`, whose Chromium smoke test is
|
||||
// `browse newtab about:blank`, reported "Chromium failed to launch" on a healthy browser.
|
||||
it('allows about:blank — the daemon opens its own first tab there', async () => {
|
||||
await expect(validateNavigationUrl('about:blank')).resolves.toBe('about:blank');
|
||||
});
|
||||
|
||||
it('allows about:blank regardless of case, since URL parsing normalises it', async () => {
|
||||
await expect(validateNavigationUrl('ABOUT:BLANK')).resolves.toBe('about:blank');
|
||||
});
|
||||
|
||||
// The allowance is about:blank EXACTLY, not the about: scheme. about:blank has no
|
||||
// origin and loads nothing; the rest of the scheme is a real surface.
|
||||
it('still blocks other about: URLs', async () => {
|
||||
await expect(validateNavigationUrl('about:config')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
await expect(validateNavigationUrl('about:net-internals')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks about:blankfoo — exact match, never a prefix test', async () => {
|
||||
await expect(validateNavigationUrl('about:blankfoo')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks javascript: scheme', async () => {
|
||||
await expect(validateNavigationUrl('javascript:alert(1)')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user