moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

314
app/tests/e2e/chat.test.ts Normal file
View File

@@ -0,0 +1,314 @@
/**
* E2E Tests: Chat Streaming, Tool Events, and Approval Gate
*
* Scenarios covered:
* 4. Chat message sent and response received (with mock agentRunner)
* 9. Tool execution — mock runner calls onToolUse, SSE includes tool event
* 10. External mutation gate — eventBus-based approval flow
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
import { startService } from '@waggle/server/local/service';
import type { AgentRunner } from '@waggle/server/local/routes/chat';
import { injectWithAuth } from './test-utils.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-e2e-'));
}
// Port 0 lets the OS assign a free port; inject() bypasses the network anyway
const TEST_PORT = 0;
/**
* Parse SSE text into structured events.
* Each event is: "event: <type>\ndata: <json>\n\n"
*/
function parseSSE(raw: string): Array<{ event: string; data: unknown }> {
const events: Array<{ event: string; data: unknown }> = [];
const blocks = raw.split('\n\n').filter(b => b.trim());
for (const block of blocks) {
const lines = block.split('\n');
let event = '';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
event = line.slice(7);
} else if (line.startsWith('data: ')) {
data = line.slice(6);
}
}
if (event && data) {
try {
events.push({ event, data: JSON.parse(data) });
} catch {
events.push({ event, data });
}
}
}
return events;
}
describe('Chat E2E', () => {
const servers: FastifyInstance[] = [];
const tmpDirs: string[] = [];
afterEach(async () => {
for (const s of servers) {
try { await s.close(); } catch { /* ignore */ }
}
servers.length = 0;
for (const d of tmpDirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
}
tmpDirs.length = 0;
});
// Scenario 4: Chat message sent and response received via SSE
it('sends chat message and receives SSE token + done events', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Inject mock agentRunner
const mockRunner: AgentRunner = async (config) => {
if (config.onToken) {
config.onToken('Hello ');
config.onToken('world');
}
return {
content: 'Hello world',
usage: { inputTokens: 10, outputTokens: 5 },
toolsUsed: [],
};
};
server.agentRunner = mockRunner;
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/chat',
payload: { message: 'Hi there' },
});
// SSE hijacks the response — status comes from raw writeHead
// inject() returns the raw body as payload
const events = parseSSE(res.payload);
// Should have token events
const tokenEvents = events.filter(e => e.event === 'token');
expect(tokenEvents.length).toBe(2);
expect((tokenEvents[0].data as { content: string }).content).toBe('Hello ');
expect((tokenEvents[1].data as { content: string }).content).toBe('world');
// Should have done event
const doneEvents = events.filter(e => e.event === 'done');
expect(doneEvents.length).toBe(1);
const doneData = doneEvents[0].data as { content: string; usage: unknown; toolsUsed: string[] };
expect(doneData.content).toBe('Hello world');
expect(doneData.toolsUsed).toEqual([]);
});
// Scenario 9: Tool execution events in SSE stream
it('includes tool events in SSE when agentRunner calls onToolUse', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
const mockRunner: AgentRunner = async (config) => {
if (config.onToken) {
config.onToken('Reading file...');
}
if (config.onToolUse) {
config.onToolUse('read_file', { path: '/src/index.ts' });
}
if (config.onToken) {
config.onToken(' Done.');
}
if (config.onToolUse) {
config.onToolUse('write_file', { path: '/src/output.ts', content: 'export {}' });
}
return {
content: 'Reading file... Done.',
usage: { inputTokens: 20, outputTokens: 10 },
toolsUsed: ['read_file', 'write_file'],
};
};
server.agentRunner = mockRunner;
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/chat',
payload: { message: 'Read the index file' },
});
const events = parseSSE(res.payload);
// Should have tool events
const toolEvents = events.filter(e => e.event === 'tool');
expect(toolEvents.length).toBe(2);
const tool1 = toolEvents[0].data as { name: string; input: Record<string, unknown> };
expect(tool1.name).toBe('read_file');
expect(tool1.input.path).toBe('/src/index.ts');
const tool2 = toolEvents[1].data as { name: string; input: Record<string, unknown> };
expect(tool2.name).toBe('write_file');
// Done event should list tools used
const doneEvents = events.filter(e => e.event === 'done');
expect(doneEvents.length).toBe(1);
const doneData = doneEvents[0].data as { toolsUsed: string[] };
expect(doneData.toolsUsed).toEqual(['read_file', 'write_file']);
});
// Scenario 10: External mutation gate — eventBus approval flow
// The approval gate is driven by the eventBus: the agent emits a
// 'gate:request' event and waits for 'gate:response'. This test
// verifies the eventBus-based flow without needing an HTTP endpoint.
it('external mutation gate: eventBus blocks and resumes on approval', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Simulate the approval gate pattern used by the desktop app:
// agentRunner emits 'gate:request' on the eventBus, waits for 'gate:response'
const gateLog: string[] = [];
const mockRunner: AgentRunner = async (config) => {
// Simulate a tool that triggers an approval gate
if (config.onToolUse) {
config.onToolUse('bash', { command: 'rm -rf /important' });
}
// Emit gate request and wait for response
const approved = await new Promise<boolean>((resolve) => {
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
gateLog.push(response.approved ? 'approved' : 'denied');
resolve(response.approved);
});
server.eventBus.emit('gate:request', {
tool: 'bash',
input: { command: 'rm -rf /important' },
requestId: 'gate-001',
});
});
if (config.onToken) {
config.onToken(approved ? 'Executed.' : 'Blocked.');
}
return {
content: approved ? 'Executed.' : 'Blocked.',
usage: { inputTokens: 15, outputTokens: 3 },
toolsUsed: approved ? ['bash'] : [],
};
};
server.agentRunner = mockRunner;
// Listen for gate requests and auto-approve
server.eventBus.on('gate:request', (req: { requestId: string }) => {
gateLog.push('request-received');
// Simulate user clicking "Approve" in the UI
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: true });
});
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/chat',
payload: { message: 'Delete the folder' },
});
const events = parseSSE(res.payload);
const doneEvents = events.filter(e => e.event === 'done');
expect(doneEvents.length).toBe(1);
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
expect(doneData.content).toBe('Executed.');
expect(doneData.toolsUsed).toEqual(['bash']);
// Verify gate flow happened in correct order
expect(gateLog.length).toBe(2);
expect(gateLog[0]).toBe('request-received');
expect(gateLog[1]).toBe('approved');
});
// Scenario 10b: External mutation gate — denial path
it('external mutation gate: eventBus blocks and returns denial', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
const gateLog: string[] = [];
const mockRunner: AgentRunner = async (config) => {
if (config.onToolUse) {
config.onToolUse('bash', { command: 'rm -rf /important' });
}
// Emit gate request and wait for response
const approved = await new Promise<boolean>((resolve) => {
server.eventBus.once('gate:response', (response: { approved: boolean }) => {
gateLog.push(response.approved ? 'approved' : 'denied');
resolve(response.approved);
});
server.eventBus.emit('gate:request', {
tool: 'bash',
input: { command: 'rm -rf /important' },
requestId: 'gate-002',
});
});
if (config.onToken) {
config.onToken(approved ? 'Executed.' : 'Blocked.');
}
return {
content: approved ? 'Executed.' : 'Blocked.',
usage: { inputTokens: 15, outputTokens: 3 },
toolsUsed: approved ? ['bash'] : [],
};
};
server.agentRunner = mockRunner;
// Listen for gate requests and auto-DENY
server.eventBus.on('gate:request', (req: { requestId: string }) => {
gateLog.push('request-received');
// Simulate user clicking "Deny" in the UI
server.eventBus.emit('gate:response', { requestId: req.requestId, approved: false });
});
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/chat',
payload: { message: 'Delete the folder' },
});
const events = parseSSE(res.payload);
const doneEvents = events.filter(e => e.event === 'done');
expect(doneEvents.length).toBe(1);
const doneData = doneEvents[0].data as { content: string; toolsUsed: string[] };
expect(doneData.content).toBe('Blocked.');
expect(doneData.toolsUsed).toEqual([]);
// Verify gate flow happened in correct order with denial
expect(gateLog.length).toBe(2);
expect(gateLog[0]).toBe('request-received');
expect(gateLog[1]).toBe('denied');
});
});