mirror of https://github.com/garrytan/gstack.git
test(sidebar): drop chat-queue-era tests, re-pin surviving invariants
The v1.14 sidebar rip (PR #1216) replaced the chat-queue path with the claude PTY terminal but missed these test files; the teardown process.exit bug then hid their failures until the suite could run to completion again. - sidebar-integration.test.ts, security-sidepanel-dom.test.ts: deleted — every test drives removed endpoints (/sidebar-command, /sidebar-chat, /sidebar-agent/*) or the ripped #security-banner chat UI. PTY-path coverage lives in terminal-agent-integration.test.ts; the rip itself is pinned as absence tests in sidebar-tabs.test.ts. - sidebar-ux.test.ts: removed stale blocks pinning the dead architecture; re-pinned invariants that survived in new form (identity-based killAgentByRecord, PTY cleanup-prompt injection, typed DOMException catch, lazy terminal spawn). - sidebar-tabs.test.ts: two assertions updated to the current source shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
383814366b
commit
514e529408
|
|
@ -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
Loading…
Reference in New Issue