This commit is contained in:
Time Attakc 2026-07-10 16:12:43 -07:00 committed by GitHub
commit 16963347c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 523 additions and 1595 deletions

View File

@ -798,19 +798,31 @@ export class BrowserManager {
const page = this.pages.get(tabId);
if (!page) throw new Error(`Tab ${tabId} not found`);
// Capture BEFORE close(): the page 'close' event handler wired in
// wirePageEvents() can fire while page.close() is awaited. It removes
// the tab from the maps and reassigns activeTabId (to 0 when no tabs
// remain), so a post-close `tabId === this.activeTabId` check is
// order-dependent — whether the event dispatches before or after
// close() resolves varies across Playwright/Chromium versions and
// machines, and losing the race means the last-tab auto-create below
// never runs, leaving the manager with zero tabs.
const wasActive = tabId === this.activeTabId;
await page.close();
this.pages.delete(tabId);
this.tabSessions.delete(tabId);
this.tabOwnership.delete(tabId);
// Switch to another tab if we closed the active one
if (tabId === this.activeTabId) {
if (wasActive) {
const remaining = [...this.pages.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1];
} else {
if (remaining.length === 0) {
// No tabs left — create a new blank one
await this.newTab();
} else if (!this.pages.has(this.activeTabId)) {
// The 'close' handler may have already switched to a valid tab;
// only reassign when activeTabId no longer points at a live tab.
this.activeTabId = remaining[remaining.length - 1];
}
}
}

View File

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

View File

@ -5,7 +5,7 @@
* A real browse server is started and commands are sent via the CLI HTTP interface.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { resolveServerScript } from '../src/cli';
@ -94,11 +94,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 ─────────────────────────────────────────────────
@ -135,7 +138,12 @@ describe('Navigation', () => {
// ─── Content Extraction ─────────────────────────────────────────
describe('Content extraction', () => {
beforeAll(async () => {
// beforeEach, NOT beforeAll: bun <1.3 runs every describe-scoped beforeAll
// eagerly at file start (in file order), so a beforeAll goto here is
// clobbered by later describes' beforeAll gotos before any test runs.
// beforeEach is scoped correctly on all bun versions, and a goto against
// the local fixture server costs only a few ms per test.
beforeEach(async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
});
@ -193,7 +201,9 @@ describe('Content extraction', () => {
// ─── JavaScript / CSS / Attrs ───────────────────────────────────
describe('Inspection', () => {
beforeAll(async () => {
// beforeEach, NOT beforeAll — see 'Content extraction' note (bun <1.3
// runs describe-scoped beforeAll hooks eagerly at file start).
beforeEach(async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
});
@ -1071,7 +1081,9 @@ describe('Dialog handling', () => {
// ─── Element State Checks (is) ─────────────────────────────────
describe('Element state checks', () => {
beforeAll(async () => {
// beforeEach, NOT beforeAll — see 'Content extraction' note (bun <1.3
// runs describe-scoped beforeAll hooks eagerly at file start).
beforeEach(async () => {
await handleWriteCommand('goto', [baseUrl + '/states.html'], bm);
});

View File

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

View File

@ -124,6 +124,17 @@ describe('Content filter hooks', () => {
clearContentFilters();
});
// Restore module-load state when this describe is done. bun test runs
// every file in one process, so the content-security module instance is
// SHARED across test files — leaving the registry empty here breaks
// security-integration.test.ts, whose pipeline test pins that the
// built-in urlBlocklistFilter is registered (content-security.ts
// registers it at module load).
afterAll(() => {
clearContentFilters();
registerContentFilter(urlBlocklistFilter);
});
test('URL blocklist detects requestbin', () => {
const result = urlBlocklistFilter('', 'https://requestbin.com/r/abc', 'text');
expect(result.safe).toBe(false);
@ -422,9 +433,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 () => {

View File

@ -220,7 +220,9 @@ describe('/command tunnel command allowlist', () => {
'return handleCommand(body, tokenInfo)'
);
expect(commandBlock).toContain("surface === 'tunnel'");
expect(commandBlock).toContain('canDispatchOverTunnel(body?.command)');
// 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');
expect(commandBlock).toContain('status: 403');

View File

@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
let stateDir: string;
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
// The script resolves GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR.
// Scrub the two higher-priority overrides from the inherited env so a
// leaked GSTACK_HOME/GSTACK_STATE_ROOT from another test file (or the
// operator shell) can never redirect reads/writes away from this test's
// temp stateDir.
const env: Record<string, string | undefined> = {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
};
if (!('GSTACK_STATE_ROOT' in extraEnv)) delete env.GSTACK_STATE_ROOT;
if (!('GSTACK_HOME' in extraEnv)) delete env.GSTACK_HOME;
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
env,
stdout: 'pipe',
stderr: 'pipe',
});

View File

@ -22,6 +22,14 @@ function run(extraEnv: Record<string, string> = {}, args: string[] = []) {
...process.env,
GSTACK_DIR: gstackDir,
GSTACK_STATE_DIR: stateDir,
// gstack-config resolves its state dir as GSTACK_STATE_ROOT >
// GSTACK_HOME > GSTACK_STATE_DIR. Other test files in the same bun
// process set process.env.GSTACK_HOME mid-suite (and operator shells
// may export it too); a leaked value would win over GSTACK_STATE_DIR
// above and point gstack-config at the wrong config.yaml, making the
// update_check config tests order-dependent. Pin all three.
GSTACK_HOME: stateDir,
GSTACK_STATE_ROOT: stateDir,
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
...extraEnv,
},

View File

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

View File

@ -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 () => {

View File

@ -1,360 +0,0 @@
/**
* Sidepanel DOM test verifies the extension's sidepanel.html/.js/.css
* actually render and react to security events correctly when loaded in
* a real Chromium.
*
* Uses Playwright + BrowserManager. The extension sidepanel is loaded via
* file:// with a stubbed window.fetch that simulates the browse server
* returning /health + /sidebar-chat responses. We inject security_event
* entries via the stubbed /sidebar-chat response and assert:
*
* * Banner renders (display: block, not display: none)
* * Title + subtitle text reflects domain + layer
* * Layer scores appear in the expandable details
* * Shield icon data-status attr flips based on /health.security.status
* * Escape key dismisses the banner
* * Expand button toggles aria-expanded + layer list visibility
*
* All 83 prior security tests cover the JS behavior in isolation; this
* test covers the integration: sidepanel.html + sidepanel.js + sidepanel.css
* + real DOM + real event dispatch.
*
* Runs in ~2s. Gate tier. Skipped if Playwright isn't available.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { chromium, type Browser, type Page } from 'playwright';
const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension');
const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`;
/**
* Eager check does Playwright have chromium installed on disk?
* test.skipIf() is evaluated at file-registration time (before beforeAll),
* so a runtime probe of `browser` state wouldn't work all tests would
* unconditionally get registered as `skip: true`. We need a sync check.
*/
const CHROMIUM_AVAILABLE = (() => {
try {
const exe = chromium.executablePath();
return !!exe && fs.existsSync(exe);
} catch {
return false;
}
})();
/**
* Seed the sidepanel so it thinks it's connected + poll-ready before
* sidepanel.js runs its connection flow. We stub chrome.runtime, chrome.tabs,
* and window.fetch so the sidepanel code paths behave as if a real browse
* server is responding.
*/
async function installStubsBeforeLoad(page: Page, scenario: {
healthSecurity?: { status: 'protected' | 'degraded' | 'inactive'; layers?: any };
securityEntries?: any[];
}): Promise<void> {
await page.addInitScript((params: any) => {
// Stub chrome.runtime for the background-service-worker connection flow.
// sendMessage supports both callback and Promise style — sidepanel.js
// uses both patterns depending on the call site.
(window as any).chrome = {
runtime: {
sendMessage: (_req: any, cb: any) => {
const payload = { connected: true, port: 34567 };
if (typeof cb === 'function') {
setTimeout(() => cb(payload), 0);
return undefined;
}
return Promise.resolve(payload);
},
lastError: null,
onMessage: { addListener: () => {} },
},
tabs: {
query: (_q: any, cb: any) => setTimeout(() => cb([{ id: 1, url: 'https://example.com' }]), 0),
onActivated: { addListener: () => {} },
onUpdated: { addListener: () => {} },
},
};
// Stub EventSource — connectSSE() throws without this because file://
// can't actually open an SSE connection to http://127.0.0.1.
(window as any).EventSource = class {
constructor() {}
addEventListener() {}
close() {}
};
// Stub fetch.
const scenarioRef = params;
const origFetch = window.fetch;
window.fetch = async function (input: any, init?: any) {
const url = String(input);
if (url.endsWith('/health')) {
return new Response(JSON.stringify({
status: 'healthy',
token: 'test-token',
mode: 'headed',
agent: { status: 'idle', runningFor: null, queueLength: 0 },
session: null,
security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {}, lastUpdated: '' },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/sidebar-chat')) {
return new Response(JSON.stringify({
entries: scenarioRef.securityEntries ?? [],
total: (scenarioRef.securityEntries ?? []).length,
agentStatus: 'idle',
activeTabId: 1,
security: scenarioRef.healthSecurity ?? { status: 'degraded', layers: {} },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/sidebar-tabs')) {
return new Response(JSON.stringify({ tabs: [] }), { status: 200 });
}
if (url.includes('/sidebar-activity')) {
return new Response('{}', { status: 200 });
}
// Fall through for anything else we didn't scenario.
if (typeof origFetch === 'function') return origFetch(input, init);
return new Response('{}', { status: 200 });
} as any;
}, scenario);
}
let browser: Browser | null = null;
beforeAll(async () => {
if (!CHROMIUM_AVAILABLE) return;
browser = await chromium.launch({ headless: true });
}, 30000);
afterAll(async () => {
if (browser) {
try { await browser.close(); } catch {}
}
});
describe('sidepanel security DOM', () => {
test.skipIf(!CHROMIUM_AVAILABLE)('shield icon reflects /health.security.status', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
});
await page.goto(SIDEPANEL_URL);
// sidepanel.js updates the shield after the first /health call
// succeeds. Give it a tick.
await page.waitForFunction(
() => document.getElementById('security-shield')?.getAttribute('data-status') === 'protected',
{ timeout: 5000 },
);
const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status'));
expect(status).toBe('protected');
// aria-label carries human-readable state
const aria = await page.$eval('#security-shield', (el) => el.getAttribute('aria-label'));
expect(aria).toContain('protected');
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('shield flips to degraded when classifier warmup is incomplete', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'degraded',
layers: { testsavant: 'off', transcript: 'ok', canary: 'ok' },
},
});
await page.goto(SIDEPANEL_URL);
await page.waitForFunction(
() => document.getElementById('security-shield')?.getAttribute('data-status') === 'degraded',
{ timeout: 5000 },
);
const status = await page.$eval('#security-shield', (el) => el.getAttribute('data-status'));
expect(status).toBe('degraded');
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('security_event entry triggers banner render with domain + layer scores', async () => {
const securityEntry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'attacker.example.com',
channel: 'tool_use:Bash',
signals: [
{ layer: 'testsavant_content', confidence: 0.92 },
{ layer: 'transcript_classifier', confidence: 0.78 },
],
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: {
status: 'protected',
layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' },
},
securityEntries: [securityEntry],
});
await page.goto(SIDEPANEL_URL);
// The banner should become visible once /sidebar-chat poll delivers the
// security_event entry and addChatEntry routes it to showSecurityBanner.
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
const displayed = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(displayed).toBe(true);
// Subtitle includes the attack domain
const subtitleText = await page.textContent('#security-banner-subtitle');
expect(subtitleText).toContain('attacker.example.com');
expect(subtitleText).toContain('prompt injection detected');
// Layer list was populated — primary layer (canary) always renders;
// signals array brings in the additional ML layers
const layers = await page.$$eval('.security-banner-layer', (els) =>
els.map((el) => el.textContent),
);
expect(layers.length).toBeGreaterThanOrEqual(1);
// Canary row expected
expect(layers.join(' ')).toMatch(/Canary|canary/);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('expand button toggles aria-expanded + reveals details', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'ensemble_agreement',
layer: 'testsavant_content',
confidence: 0.88,
domain: 'example.com',
signals: [
{ layer: 'testsavant_content', confidence: 0.88 },
{ layer: 'transcript_classifier', confidence: 0.71 },
],
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
// Initially collapsed
const initialAria = await page.$eval('#security-banner-expand', (el) =>
el.getAttribute('aria-expanded'),
);
expect(initialAria).toBe('false');
const initialHidden = await page.$eval('#security-banner-details', (el) =>
(el as HTMLElement).hidden,
);
expect(initialHidden).toBe(true);
// Click expand
await page.click('#security-banner-expand');
const expandedAria = await page.$eval('#security-banner-expand', (el) =>
el.getAttribute('aria-expanded'),
);
expect(expandedAria).toBe('true');
const expandedHidden = await page.$eval('#security-banner-details', (el) =>
(el as HTMLElement).hidden,
);
expect(expandedHidden).toBe(false);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('Escape key dismisses an open banner', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'evil.example.com',
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
// Hit Escape — should hide the banner
await page.keyboard.press('Escape');
// Wait a tick for the event handler to run
await page.waitForFunction(
() => {
const el = document.getElementById('security-banner');
return el ? window.getComputedStyle(el).display === 'none' : false;
},
{ timeout: 2000 },
);
const stillVisible = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(stillVisible).toBe(false);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('close button dismisses banner', async () => {
const entry = {
id: 1,
ts: '2026-04-20T00:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'evil.example.com',
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsBeforeLoad(page, {
healthSecurity: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
securityEntries: [entry],
});
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner', { state: 'visible', timeout: 5000 });
await page.click('#security-banner-close');
await page.waitForFunction(
() => {
const el = document.getElementById('security-banner');
return el ? window.getComputedStyle(el).display === 'none' : false;
},
{ timeout: 2000 },
);
const displayed = await page.$eval('#security-banner', (el) =>
window.getComputedStyle(el).display !== 'none',
);
expect(displayed).toBe(false);
await context.close();
}, 15000);
});

View File

@ -1,328 +0,0 @@
/**
* Layer 2: Server HTTP integration tests for sidebar endpoints.
* Starts the browse server as a subprocess (no browser via BROWSE_HEADLESS_SKIP),
* exercises sidebar HTTP endpoints with fetch(). No Chrome, no Claude, no sidebar-agent.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let serverProc: Subprocess | null = null;
let serverPort: number = 0;
let authToken: string = '';
let tmpDir: string = '';
let stateFile: string = '';
let queueFile: string = '';
async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise<Response> {
const { noAuth, ...fetchOpts } = opts;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOpts.headers as Record<string, string> || {}),
};
if (!noAuth && !headers['Authorization'] && authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers });
}
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-integ-'));
stateFile = path.join(tmpDir, 'browse.json');
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
// Ensure queue dir exists
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
const serverScript = path.resolve(__dirname, '..', 'src', 'server.ts');
serverProc = spawn(['bun', 'run', serverScript], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_HEADLESS_SKIP: '1',
BROWSE_PORT: '0',
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_IDLE_TIMEOUT: '300',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Wait for state file
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (fs.existsSync(stateFile)) {
try {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (state.port && state.token) {
serverPort = state.port;
authToken = state.token;
break;
}
} catch {}
}
await new Promise(r => setTimeout(r, 100));
}
if (!serverPort) throw new Error('Server did not start in time');
}, 20000);
afterAll(() => {
if (serverProc) { try { serverProc.kill(); } catch {} }
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
// Reset state between tests — creates a fresh session, clears all queues
async function resetState() {
await api('/sidebar-session/new', { method: 'POST' });
fs.writeFileSync(queueFile, '');
}
describe('sidebar auth', () => {
test('rejects request without auth token', async () => {
const resp = await api('/sidebar-command', {
method: 'POST',
noAuth: true,
body: JSON.stringify({ message: 'test' }),
});
expect(resp.status).toBe(401);
});
test('rejects request with wrong token', async () => {
const resp = await api('/sidebar-command', {
method: 'POST',
headers: { 'Authorization': 'Bearer wrong-token' },
body: JSON.stringify({ message: 'test' }),
});
expect(resp.status).toBe(401);
});
test('accepts request with correct token', async () => {
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'hello' }),
});
expect(resp.status).toBe(200);
// Clean up
await api('/sidebar-agent/kill', { method: 'POST' });
});
});
describe('sidebar-command → queue', () => {
test('writes queue entry with activeTabUrl', async () => {
await resetState();
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: 'what is on this page?',
activeTabUrl: 'https://example.com/test-page',
}),
});
expect(resp.status).toBe(200);
const data = await resp.json();
expect(data.ok).toBe(true);
// Give server a moment to write queue
await new Promise(r => setTimeout(r, 100));
const content = fs.readFileSync(queueFile, 'utf-8').trim();
const lines = content.split('\n').filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const entry = JSON.parse(lines[lines.length - 1]);
// Active tab URL is carried on the queue entry metadata (entry.pageUrl),
// NOT inlined into the prompt. The system prompt deliberately tells
// Claude to run `browse url` instead of trusting any URL in the prompt
// body — that's the prompt-injection-via-URL defense. See spawnClaude
// in browse/src/server.ts.
expect(entry.pageUrl).toBe('https://example.com/test-page');
await api('/sidebar-agent/kill', { method: 'POST' });
});
test('falls back when activeTabUrl is null', async () => {
await resetState();
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'test', activeTabUrl: null }),
});
await new Promise(r => setTimeout(r, 100));
const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const entry = JSON.parse(lines[lines.length - 1]);
// No browser → playwright URL is 'about:blank'
expect(entry.pageUrl).toBe('about:blank');
await api('/sidebar-agent/kill', { method: 'POST' });
});
test('rejects chrome:// activeTabUrl and falls back', async () => {
await resetState();
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'test', activeTabUrl: 'chrome://extensions' }),
});
await new Promise(r => setTimeout(r, 100));
const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const entry = JSON.parse(lines[lines.length - 1]);
expect(entry.pageUrl).toBe('about:blank');
await api('/sidebar-agent/kill', { method: 'POST' });
});
test('rejects empty message', async () => {
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: '' }),
});
expect(resp.status).toBe(400);
});
});
describe('sidebar-agent/event → chat buffer', () => {
test('agent events appear in /sidebar-chat', async () => {
await resetState();
// Post pre-processed agent event. The server's processAgentEvent
// handles the simplified types that sidebar-agent.ts emits (text,
// text_delta, tool_use, result, agent_error, security_event), NOT
// the raw Claude streaming format — pre-processing lives in
// sidebar-agent.ts, not in the server.
await api('/sidebar-agent/event', {
method: 'POST',
body: JSON.stringify({
type: 'text',
text: 'Hello from mock agent',
}),
});
const chatData = await (await api('/sidebar-chat?after=0')).json();
const textEntry = chatData.entries.find((e: any) => e.type === 'text');
expect(textEntry).toBeDefined();
expect(textEntry.text).toBe('Hello from mock agent');
});
test('agent_done transitions status to idle', async () => {
await resetState();
// Start a command so agent is processing
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'test' }),
});
// Verify processing
let session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('processing');
// Send agent_done
await api('/sidebar-agent/event', {
method: 'POST',
body: JSON.stringify({ type: 'agent_done' }),
});
session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
});
});
describe('message queuing', () => {
test('queues message when agent is processing', async () => {
await resetState();
// First message starts processing
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'first' }),
});
// Second message gets queued
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'second' }),
});
const data = await resp.json();
expect(data.ok).toBe(true);
expect(data.queued).toBe(true);
expect(data.position).toBe(1);
await api('/sidebar-agent/kill', { method: 'POST' });
});
test('returns 429 when queue is full', async () => {
await resetState();
// First message starts processing
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'first' }),
});
// Fill queue (max 5)
for (let i = 0; i < 5; i++) {
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: `fill-${i}` }),
});
}
// 7th message should be rejected
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'overflow' }),
});
expect(resp.status).toBe(429);
await api('/sidebar-agent/kill', { method: 'POST' });
});
});
describe('chat clear', () => {
test('clears chat buffer', async () => {
await resetState();
// Add some entries
await api('/sidebar-agent/event', {
method: 'POST',
body: JSON.stringify({ type: 'text', text: 'to be cleared' }),
});
await api('/sidebar-chat/clear', { method: 'POST' });
const data = await (await api('/sidebar-chat?after=0')).json();
expect(data.entries.length).toBe(0);
expect(data.total).toBe(0);
});
});
describe('agent kill', () => {
test('kill adds error entry and returns to idle', async () => {
await resetState();
// Start a command so agent is processing
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'kill me' }),
});
let session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('processing');
// Kill the agent
const killResp = await api('/sidebar-agent/kill', { method: 'POST' });
expect(killResp.status).toBe(200);
// Check chat for error entry
const chatData = await (await api('/sidebar-chat?after=0')).json();
const errorEntry = chatData.entries.find((e: any) => e.error === 'Killed by user');
expect(errorEntry).toBeDefined();
// Agent should be idle (no queue items to auto-process)
session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
});
});

View File

@ -157,7 +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'));
expect(fn).toContain('ws && ws.close()');
// 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()');
@ -222,8 +224,17 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
});
test('Terminal-agent spawn survives', () => {
expect(CLI_SRC).toContain('terminal-agent.ts');
expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/);
// 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',
);
expect(CONTROL_SRC).toContain('terminal-agent.ts');
expect(CONTROL_SRC).toMatch(/\.spawn\(\['bun',\s*'run',\s*script\]/);
});
});

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -22,6 +22,26 @@ import * as os from 'os';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const BASH = '/bin/bash';
// Bun.spawn PTY support (the `terminal` spawn option) is required for the
// agent to stream PTY output. On older Bun (< 1.3) the option is silently
// ignored — proc.terminal is undefined, terminal.write() no-ops, and the
// round-trip can never produce output. Feature-detect instead of version-
// sniffing, and SKIP (not fake-pass) the output-dependent test on runtimes
// that genuinely lack the capability. All auth/handshake/control-frame
// tests still run everywhere — they don't need a live PTY.
const BUN_HAS_PTY = (() => {
try {
const probe = (Bun as any).spawn(['/bin/sh', '-c', 'exit 0'], {
terminal: { rows: 2, cols: 2, data() {} },
});
const has = !!probe.terminal;
try { probe.kill(); } catch {}
return has;
} catch {
return false;
}
})();
let stateDir: string;
let agentProc: any;
let agentPort: number;
@ -128,7 +148,11 @@ describe('terminal-agent: /ws gates', () => {
});
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
test('binary writes go to PTY stdin, output streams back', async () => {
// Skipped when the Bun runtime lacks PTY spawn support (see BUN_HAS_PTY).
// Explicit 15s timeout: the test legitimately waits up to 5s for the WS
// open plus up to 5s for PTY output, which overflows bun's 5s default and
// leaves stale assertions that get mis-attributed to the NEXT test.
test.skipIf(!BUN_HAS_PTY)('binary writes go to PTY stdin, output streams back', async () => {
const cookie = 'rt-token-must-be-at-least-seventeen-chars-long';
const granted = await grantToken(cookie);
expect(granted.status).toBe(200);
@ -180,7 +204,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
try { ws.close(); } catch {}
// Give cleanup a moment.
await Bun.sleep(200);
});
}, 15000);
test('Sec-WebSocket-Protocol auth path: browser-style upgrade with token in protocol', async () => {
// This is the path the actual browser extension takes. Cross-port
@ -199,32 +223,57 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
await grantToken(token);
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
// because Bun's test-client WebSocket constructor doesn't propagate
// We exercise the protocol path with a raw TCP handshake, because
// Bun's test-client WebSocket constructor doesn't propagate
// `protocols` cleanly when also passed `headers` (the constructor
// detects the third-arg form unreliably). Real browsers (Chromium)
// use the standard protocols arg fine — the server-side handler is
// identical either way, so this test still locks the load-bearing
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
// and echoes the protocol back so a browser would accept the upgrade.
// detects the third-arg form unreliably), and fetch()+Upgrade headers
// never resolves for a 101 response on Bun < 1.3 (it only completes
// for ordinary statuses, so the success path hangs). Real browsers
// (Chromium) use the standard protocols arg fine — the server-side
// handler is identical either way, so this test still locks the
// load-bearing invariant: the agent accepts a token via
// Sec-WebSocket-Protocol and echoes the protocol back so a browser
// would accept the upgrade.
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': handshakeKey,
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
'Origin': 'chrome-extension://test-extension-id',
},
const requestHead = [
'GET /ws HTTP/1.1',
`Host: 127.0.0.1:${agentPort}`,
'Connection: Upgrade',
'Upgrade: websocket',
'Sec-WebSocket-Version: 13',
`Sec-WebSocket-Key: ${handshakeKey}`,
`Sec-WebSocket-Protocol: gstack-pty.${token}`,
'Origin: chrome-extension://test-extension-id',
'',
'',
].join('\r\n');
const net = await import('node:net');
const responseHead = await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const socket = net.connect(agentPort, '127.0.0.1', () => socket.write(requestHead));
const finish = () => {
clearTimeout(timer);
socket.destroy();
resolve(Buffer.concat(chunks).toString('utf8'));
};
const timer = setTimeout(finish, 3000);
socket.on('data', (d: Buffer) => {
chunks.push(d);
// Response head complete once we see the blank line.
if (Buffer.concat(chunks).toString('utf8').includes('\r\n\r\n')) finish();
});
socket.on('error', (e: Error) => { clearTimeout(timer); reject(e); });
socket.on('close', finish);
});
// 101 Switching Protocols + protocol echoed back = browser would accept.
// 401/403/anything else = browser would close the connection immediately
// (the bug we hit in manual dogfood).
expect(resp.status).toBe(101);
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
const statusLine = responseHead.split('\r\n')[0] || '';
expect(statusLine).toContain('101');
expect(responseHead.toLowerCase()).toContain('upgrade: websocket');
expect(responseHead.toLowerCase()).toContain(`sec-websocket-protocol: gstack-pty.${token}`.toLowerCase());
});
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {

View File

@ -144,17 +144,33 @@ describe('Source-level guard: terminal-agent', () => {
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: {"),
);
expect(upgradeBlock).not.toContain('spawnClaude(');
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
// Spawn must be invoked from the message handler (lazy on first byte).
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
expect(messageHandler).toContain('spawnClaude(');
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', () => {

View File

@ -46,13 +46,19 @@ afterEach(async () => {
serverProc = null;
});
function spawnServer(env: Record<string, string>, port: number): Subprocess {
// No fixed BROWSE_PORT: these tests never dial the server's HTTP port —
// they only watch stdout markers and process liveness. BROWSE_PORT=0 lets
// the server pick a random free port (its default), so concurrent runs of
// this file (sibling worktrees, pr-test-env checkouts) and leftover
// servers from interrupted runs can't collide on a hardcoded port and
// kill the fresh spawn with EADDRINUSE before the 2s liveness check.
function spawnServer(env: Record<string, string>): Subprocess {
const stateFile = path.join(tmpDir, 'browse-state.json');
return spawn(['bun', 'run', SERVER_SCRIPT], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_PORT: String(port),
BROWSE_PORT: '0',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
@ -97,7 +103,7 @@ async function readStdoutUntil(
describe('parent-process watchdog (v0.18.1.0)', () => {
test('BROWSE_PARENT_PID=0 disables the watchdog', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-'));
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901);
serverProc = spawnServer({ BROWSE_PARENT_PID: '0' });
const out = await readStdoutUntil(
serverProc,
@ -115,10 +121,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
// Pass a bogus parent PID to prove BROWSE_HEADED takes precedence.
// If the server-side guard regresses, the watchdog would try to poll
// this PID and eventually fire on the "dead parent."
serverProc = spawnServer(
{ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' },
34902,
);
serverProc = spawnServer({ BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' });
const out = await readStdoutUntil(
serverProc,
@ -137,7 +140,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
const parentPid = parentProc.pid!;
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) });
const serverPid = serverProc.pid!;
// Give the server a moment to start and register the watchdog interval.

View File

@ -361,16 +361,27 @@ describe("daemon /shutdown", () => {
await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
);
// Now non-done count is 0 — handler should return shuttingDown:true.
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
// after 50ms which would tear down the test runner); instead we just
// observe the immediate response.
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Reset state for subsequent tests; the shutdown timer will be a no-op
// because the next resetForTest flips shuttingDown back to false.
// The handler arms setTimeout(gracefulShutdown, 50), and gracefulShutdown
// arms setTimeout(process.exit, 50). bun test runs ALL files in one
// process, so letting that exit fire would kill the whole suite ~100ms
// later (exit 0, no summary — see test/no-suicide-exit.test.ts). Stub
// process.exit, wait past both timers so they fire harmlessly while
// stubbed, then restore. (resetForTest does NOT defuse the timers: the
// exit callback is unconditional.)
const origExit = process.exit;
(process as any).exit = (() => undefined) as any;
try {
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Let both 50ms timers (gracefulShutdown, then its process.exit) fire
// against the stub before restoring the real process.exit.
await new Promise((resolve) => setTimeout(resolve, 200));
} finally {
(process as any).exit = origExit;
}
// Reset state for subsequent tests (gracefulShutdown set shuttingDown).
resetDaemon();
});
});

View File

@ -22,6 +22,16 @@ import * as fs from 'fs';
import * as path from 'path';
let bm: BrowserManager;
// The command handlers take (command, args, session: TabSession, bm) — mirror
// the real call sites (browse/src/cli.ts, browse/test/commands.test.ts) by
// resolving the active TabSession from the manager on every call. Passing the
// manager itself where a session is expected breaks as soon as a handler uses
// a session method the manager doesn't delegate (e.g. clearLoadedHtml).
const writeCmd = (cmd: string, args: string[]) =>
handleWriteCommand(cmd, args, bm.getActiveSession(), bm);
const readCmd = (cmd: string, args: string[]) =>
handleReadCommand(cmd, args, bm.getActiveSession(), bm);
let baseUrl: string;
let server: ReturnType<typeof Bun.serve>;
let tmpDir: string;
@ -121,10 +131,15 @@ beforeAll(async () => {
await bm.launch();
});
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 {}
});
// ─── The critical test: browser click → file on disk ─────────────
@ -137,32 +152,32 @@ describe('Submit: browser click → feedback.json on disk', () => {
serverState = 'serving';
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Verify the board detects HTTP mode (so postFeedback will actually fetch
// instead of falling into the file:// DOM-only path)
const httpDetected = await handleReadCommand('js', [
const httpDetected = await readCmd('js', [
"location.protocol === 'http:' || location.protocol === 'https:'"
], bm);
]);
expect(httpDetected).toBe('true');
// User picks variant A, rates it 5 stars
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
], bm);
]);
// User adds overall feedback
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("overall-feedback").value = "Ship variant A"'
], bm);
]);
// User clicks Submit
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("submit-btn").click()'
], bm);
]);
// Wait a beat for the async POST to complete
await new Promise(r => setTimeout(r, 300));
@ -184,21 +199,21 @@ describe('Submit: browser click → feedback.json on disk', () => {
await new Promise(r => setTimeout(r, 500));
// After submit, the page should be read-only
const submitBtnExists = await handleReadCommand('js', [
const submitBtnExists = await readCmd('js', [
'document.getElementById("submit-btn").style.display'
], bm);
]);
// submit button is hidden after post-submit lifecycle
expect(submitBtnExists).toBe('none');
const successVisible = await handleReadCommand('js', [
const successVisible = await readCmd('js', [
'document.getElementById("success-msg").style.display'
], bm);
]);
expect(successVisible).toBe('block');
// Success message should mention /design-shotgun
const successText = await handleReadCommand('js', [
const successText = await readCmd('js', [
'document.getElementById("success-msg").textContent'
], bm);
]);
expect(successText).toContain('design-shotgun');
});
});
@ -211,17 +226,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
serverState = 'serving';
// Fresh page
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// User clicks "Totally different" chiclet
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
], bm);
]);
// User clicks Regenerate
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
// Wait for async POST
await new Promise(r => setTimeout(r, 300));
@ -244,12 +259,12 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Click "More like this" on variant B (index 1)
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll(".more-like-this")[1].click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
@ -263,21 +278,21 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
test('board shows spinner after regenerate (user stays on same tab)', async () => {
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
// Board should show "Generating new designs..." text
const bodyText = await handleReadCommand('js', [
const bodyText = await readCmd('js', [
'document.body.textContent'
], bm);
]);
expect(bodyText).toContain('Generating new designs');
});
});
@ -291,15 +306,15 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Step 1: User clicks Regenerate
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
@ -329,21 +344,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
expect(serverState).toBe('serving');
// Step 4: Board auto-refreshes (simulated by navigating again)
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Verify the board is fresh (no prior picks)
const status = await handleReadCommand('js', [
const status = await readCmd('js', [
'document.getElementById("status").textContent'
], bm);
]);
expect(status).toBe('');
// Step 5: User picks variant C on round 2 and submits
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("submit-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));

View File

@ -72,7 +72,11 @@ describe("generateVariant Retry-After handling", () => {
test("HTTP-date: honors a future date with no extra leading exponential", async () => {
const calls: CallRecord[] = [];
const future = new Date(Date.now() + 3000).toUTCString();
// HTTP-date has whole-second granularity: toUTCString() truncates the ms
// component, which would shave up to 999ms off the intended wait and flake
// the >= 2500 floor. Align the base to the next whole second so the
// serialized date is a full 3s (up to 3.999s) in the future.
const future = new Date(Math.ceil(Date.now() / 1000) * 1000 + 3000).toUTCString();
const fetchFn = makeStubFetch([rateLimited(future), successResponse()], calls);
const result = await generateVariant(

View File

@ -15,8 +15,21 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
// "On PATH" is not enough: a broken install (e.g. the npm wrapper missing its
// platform binary @openai/codex-darwin-arm64) resolves via `which` but can't
// answer any subcommand, so every assertion would fail on wrapper stack traces
// instead of real flag-semantics regressions. Probe `codex --version` and only
// run when the CLI actually executes; a working codex still gets the full
// assertions.
const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8' }).stdout.trim();
const codexAvailable = codexPath.length > 0;
const codexRuns =
codexPath.length > 0 &&
spawnSync('codex', ['--version'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10_000,
}).status === 0;
const codexAvailable = codexRuns;
describe.skipIf(!codexAvailable)(
'codex exec resume — flag semantics (live CLI smoke; closes #1270 regex-only gap)',

View File

@ -25,7 +25,15 @@ const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install');
// dirs — this keeps `gbrain` out of PATH deterministically across dev machines
// while still finding jq, git, curl, sed, cat, etc. Each test can prepend a
// fake-gbrain dir when it wants to simulate presence.
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
//
// One wrinkle: bin/gstack-gbrain-detect's shebang is `#!/usr/bin/env -S bun run`,
// so the child's PATH must contain `bun`. On dev machines bun usually lives in
// ~/.bun/bin, which we deliberately exclude (it also holds a real `gbrain` from
// `bun link`). Expose ONLY the running bun interpreter through a shim dir,
// appended last so system dirs still win for everything else.
const BUN_SHIM_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-shim-'));
fs.symlinkSync(process.execPath, path.join(BUN_SHIM_DIR, 'bun'));
const SAFE_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:${BUN_SHIM_DIR}`;
let tmpHome: string;
let tmpHomeReal: string;

View File

@ -177,6 +177,13 @@ describe('gen-skill-docs', () => {
// whose plain `description:` scalar contains an interior ": " (read as a nested
// mapping). Parse EVERY generated frontmatter block with a strict YAML parser,
// not just string-check that name:/description: exist.
//
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
// parser (the repo is deliberately zero-dep for YAML). CI pins
// `bun-version: latest`, so coverage is guaranteed there — on an old local
// bun we skip rather than false-fail.
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
function frontmatterBlock(content: string): string {
expect(content.startsWith('---\n')).toBe(true);
const end = content.indexOf('\n---', 4);
@ -184,7 +191,7 @@ describe('gen-skill-docs', () => {
return content.slice(4, end);
}
test('every generated SKILL.md frontmatter parses as strict YAML', () => {
test.skipIf(!hasBunYaml)('every generated SKILL.md frontmatter parses as strict YAML', () => {
for (const skill of CLAUDE_GENERATED_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
const fm = frontmatterBlock(content);
@ -196,7 +203,7 @@ describe('gen-skill-docs', () => {
}
});
test('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
test.skipIf(!hasBunYaml)('every generated Codex (.agents/skills) frontmatter parses as strict YAML', () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if external hosts not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {

View File

@ -249,11 +249,14 @@ body
// ── withErrorContext ───────────────────────────────────────────────────────
describe("withErrorContext", () => {
let savedHome: string | undefined;
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
// beforeEach clobbers the real value with the previous test's temp dir
// after test #1, so the afterAll restore leaked a dead temp path into
// process.env for every test file that ran after this one.
const savedHome = process.env.GSTACK_HOME;
let testHome: string;
beforeEach(() => {
savedHome = process.env.GSTACK_HOME;
testHome = mkdtempSync(join(tmpdir(), "gstack-test-home-"));
process.env.GSTACK_HOME = testHome;
});
@ -312,18 +315,20 @@ describe("withErrorContext", () => {
// ── detectEngineTier ───────────────────────────────────────────────────────
describe("detectEngineTier", () => {
let savedHome: string | undefined;
let savedGbrainHome: string | undefined;
let savedRealHome: string | undefined;
let savedPath: string | undefined;
// Captured ONCE at collection time, NOT in beforeEach. Saving inside
// beforeEach clobbers the real values with the previous test's temp dirs
// after test #1, so the afterAll restore leaked dead gstack-test-engine-*
// temp paths into GSTACK_HOME / GBRAIN_HOME / HOME for every test file
// that ran after this one (broke gstack-config + make-pdf format-gate in
// full-suite runs).
const savedHome = process.env.GSTACK_HOME;
const savedGbrainHome = process.env.GBRAIN_HOME;
const savedRealHome = process.env.HOME;
const savedPath = process.env.PATH;
let testHome: string;
let testGbrainHome: string;
beforeEach(() => {
savedHome = process.env.GSTACK_HOME;
savedGbrainHome = process.env.GBRAIN_HOME;
savedRealHome = process.env.HOME;
savedPath = process.env.PATH;
testHome = mkdtempSync(join(tmpdir(), "gstack-test-engine-"));
testGbrainHome = mkdtempSync(join(tmpdir(), "gstack-test-gbrain-"));
process.env.GSTACK_HOME = testHome;

View File

@ -0,0 +1,49 @@
/**
* Guard: no test file may schedule a delayed process.exit().
*
* `bun test` runs EVERY test file in one process. The pattern of arming a
* 500ms timer in afterAll whose callback calls process.exit(0) once used
* in several browse/design tests as a "bm.close() can hang" workaround
* assumes each file gets its own process. It doesn't: the armed timer fires
* 500ms later, mid-way through a LATER test file, and kills the entire
* suite with exit code 0 and no summary. The truncated run silently masks
* every downstream failure (observed: only ~16 of 434 files ran, shell
* exit 0).
*
* This test statically scans every *.test.ts in the repo and fails if any
* schedules process.exit via setTimeout. Teardown must only release the
* file's own resources (e.g. `await bm.close()` BrowserManager.close()
* is already time-boxed internally) never terminate the shared runner.
*
* If a future test legitimately needs this pattern inside a child-process
* script (template literal passed to `bun -e`), split the child script
* into a fixture file instead of exempting it here.
*/
import { test, expect } from 'bun:test';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dir, '..');
// Matches a setTimeout whose arrow callback (with or without an argument)
// immediately calls process.exit. Doesn't match its own escaped source text
// (the backslashes in this regex literal prevent a literal-text match).
const DELAYED_EXIT = /setTimeout\(\s*(?:\(\s*\)|\(?\w+\)?)\s*=>\s*process\.exit\(/;
test('no test file schedules a delayed process.exit (kills the whole bun test run)', () => {
const glob = new Bun.Glob('**/*.test.ts');
const violations: string[] = [];
for (const rel of glob.scanSync({ cwd: repoRoot })) {
if (rel.includes('node_modules/')) continue;
const source = fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
if (DELAYED_EXIT.test(lines[i])) {
violations.push(`${rel}:${i + 1}: ${lines[i].trim()}`);
}
}
}
expect(violations).toEqual([]);
});

View File

@ -18,8 +18,14 @@ function extractFrontmatter(content: string): string {
return content.slice(4, fmEnd);
}
// Bun.YAML landed in bun 1.3; older local bun has no in-process strict YAML
// parser (the repo is deliberately zero-dep for YAML). CI pins
// `bun-version: latest`, so coverage is guaranteed there — on an old local
// bun we skip rather than false-fail.
const hasBunYaml = typeof (Bun as any).YAML?.parse === 'function';
describe('OpenClaw native skills', () => {
test('frontmatter parses as YAML and keeps only name + description', () => {
test.skipIf(!hasBunYaml)('frontmatter parses as YAML and keeps only name + description', () => {
for (const skill of OPENCLAW_NATIVE_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
const frontmatter = extractFrontmatter(content);

View File

@ -35,6 +35,12 @@ function runConfig(args: string[], extraEnv: Record<string, string> = {}): { std
encoding: 'utf-8',
env: {
...process.env,
// HOME isolation: endpoint_hash() reads $HOME/.claude.json for the
// gbrain MCP URL. Pointing HOME at the empty TMP_HOME makes it
// deterministically 'local' regardless of the developer's real
// ~/.claude.json (which would otherwise change the persisted key
// namespace to user_slug_at_<sha8-of-url>).
HOME: TMP_HOME,
...extraEnv,
},
timeout: 5000,
@ -92,7 +98,9 @@ describe('resolve-user-slug fallback chain', () => {
const configFile = join(TMP_HOME, 'config.yaml');
expect(existsSync(configFile)).toBe(true);
const content = readFileSync(configFile, 'utf-8');
expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m);
// HOME is isolated to the empty TMP_HOME, so endpoint_hash() is
// deterministically the literal 'local' on every machine.
expect(content).toMatch(/^user_slug_at_local:\s+persisttest/m);
});
test('subsequent calls return same slug (stable across sessions)', () => {