mirror of https://github.com/garrytan/gstack.git
Merge b01188fd3a into 7c9df1c568
This commit is contained in:
commit
16963347c7
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) ────────────
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue