336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { MindDB, FrameStore, SessionStore } from '@waggle/core';
|
|
import { buildLocalServer } from '../src/local/index.js';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { injectWithAuth } from './test-utils.js';
|
|
|
|
describe('Local Server Mode', () => {
|
|
let server: FastifyInstance;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
// Create a temp directory for test data
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-local-test-'));
|
|
|
|
// Create personal.mind with some test data
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const sessions = new SessionStore(mind);
|
|
const frames = new FrameStore(mind);
|
|
// Create sessions first (FK constraint: memory_frames.gop_id → sessions.gop_id)
|
|
const s1 = sessions.create('test-project');
|
|
const s2 = sessions.create('test-project-2');
|
|
frames.createIFrame(s1.gop_id, 'Waggle is an AI agent platform', 'normal');
|
|
frames.createIFrame(s2.gop_id, 'Memory search test content', 'important');
|
|
mind.close();
|
|
|
|
// Build the local server
|
|
server = await buildLocalServer({ dataDir: tmpDir });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
// Clean up temp dir
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// --- Health check ---
|
|
describe('health check', () => {
|
|
it('returns mode: local with structured health', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/health' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
// In test mode (no LLM provider initialized), status is not 'ok'
|
|
expect(['ok', 'degraded', 'unavailable']).toContain(body.status);
|
|
expect(body.mode).toBe('local');
|
|
expect(body.timestamp).toBeDefined();
|
|
// Deep health fields present
|
|
expect(body.llm).toBeDefined();
|
|
expect(body.llm.provider).toBeDefined();
|
|
expect(body.llm.health).toBeDefined();
|
|
expect(body.database).toBeDefined();
|
|
expect(body.database.healthy).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- Workspace CRUD ---
|
|
describe('workspace CRUD', () => {
|
|
let createdId: string;
|
|
|
|
it('creates a workspace', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Test Project', group: 'Work', icon: 'rocket' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.name).toBe('Test Project');
|
|
expect(body.group).toBe('Work');
|
|
expect(body.icon).toBe('rocket');
|
|
expect(body.id).toBeDefined();
|
|
expect(body.created).toBeDefined();
|
|
createdId = body.id;
|
|
});
|
|
|
|
it('lists workspaces', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/workspaces' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(Array.isArray(body)).toBe(true);
|
|
expect(body.length).toBeGreaterThanOrEqual(1);
|
|
expect(body.some((w: { id: string }) => w.id === createdId)).toBe(true);
|
|
});
|
|
|
|
it('gets a workspace by id', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: `/api/workspaces/${createdId}` });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.id).toBe(createdId);
|
|
expect(body.name).toBe('Test Project');
|
|
});
|
|
|
|
it('returns 404 for non-existent workspace', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/workspaces/does-not-exist' });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('updates a workspace', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'PUT',
|
|
url: `/api/workspaces/${createdId}`,
|
|
payload: { name: 'Updated Project', model: 'gpt-4o' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.name).toBe('Updated Project');
|
|
expect(body.model).toBe('gpt-4o');
|
|
});
|
|
|
|
it('deletes a workspace', async () => {
|
|
const res = await injectWithAuth(server, { method: 'DELETE', url: `/api/workspaces/${createdId}` });
|
|
expect(res.statusCode).toBe(204);
|
|
|
|
// Verify it's gone
|
|
const getRes = await injectWithAuth(server, { method: 'GET', url: `/api/workspaces/${createdId}` });
|
|
expect(getRes.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 400 when creating without required fields', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'No Group' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// --- Chat SSE ---
|
|
describe('chat SSE', () => {
|
|
it('returns SSE stream when agent runner is set', async () => {
|
|
// Inject a mock agent runner for this test
|
|
server.agentRunner = async (config) => {
|
|
if (config.onToken) config.onToken('Hi');
|
|
return {
|
|
content: 'Hi',
|
|
toolsUsed: [],
|
|
usage: { inputTokens: 1, outputTokens: 1 },
|
|
};
|
|
};
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/chat',
|
|
payload: { message: 'Hello world', workspace: 'test-ws' },
|
|
});
|
|
expect(res.headers['content-type']).toBe('text/event-stream');
|
|
expect(res.body).toContain('event: token');
|
|
expect(res.body).toContain('event: done');
|
|
|
|
// Clean up
|
|
server.agentRunner = undefined;
|
|
});
|
|
|
|
it('returns 400 without message', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/chat',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// --- Memory search ---
|
|
describe('memory search', () => {
|
|
it('returns search results for matching query', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/search?q=waggle',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.results).toBeDefined();
|
|
expect(body.count).toBeGreaterThanOrEqual(1);
|
|
expect(body.results[0].content).toContain('Waggle');
|
|
});
|
|
|
|
it('returns empty results for non-matching query', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/search?q=xyznonexistent',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.count).toBe(0);
|
|
});
|
|
|
|
it('returns 400 without query parameter', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/search',
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('returns normalized frames from /api/memory/frames endpoint', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/frames?limit=10',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.results).toBeDefined();
|
|
expect(Array.isArray(body.results)).toBe(true);
|
|
// Frames should have camelCase field names (UI shape)
|
|
if (body.results.length > 0) {
|
|
const frame = body.results[0];
|
|
expect(frame.frameType).toBeDefined();
|
|
expect(frame.timestamp).toBeDefined();
|
|
expect(frame.source).toBeDefined();
|
|
// Should NOT have raw snake_case fields
|
|
expect(frame.frame_type).toBeUndefined();
|
|
expect(frame.created_at).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('returns normalized fields from search results', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/search?q=waggle',
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
if (body.results.length > 0) {
|
|
const frame = body.results[0];
|
|
expect(frame.frameType).toBeDefined();
|
|
expect(frame.timestamp).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('preserves imported frame provenance in search results', async () => {
|
|
const marker = `browserprovenance${Date.now()}`;
|
|
const createRes = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/memory/frames?extract=false',
|
|
payload: {
|
|
content: `Browser Companion provenance marker ${marker}`,
|
|
source: 'import',
|
|
importance: 'normal',
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
|
|
const framesRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/frames?limit=20',
|
|
});
|
|
const framesBody = JSON.parse(framesRes.body);
|
|
const frame = framesBody.results.find((item: { content?: string }) => item.content?.includes(marker));
|
|
expect(frame?.source).toBe('import');
|
|
|
|
const searchRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: `/api/memory/search?q=${encodeURIComponent(marker)}`,
|
|
});
|
|
expect(searchRes.statusCode).toBe(200);
|
|
const searchBody = JSON.parse(searchRes.body);
|
|
const result = searchBody.results.find((item: { content?: string }) => item.content?.includes(marker));
|
|
expect(result?.source).toBe('import');
|
|
expect(result?.source_mind).toBe('personal');
|
|
});
|
|
|
|
it('PATCH /api/memory/frames/:id/access atomically increments access count', async () => {
|
|
// Find an existing personal-mind frame id
|
|
const listRes = await injectWithAuth(server, {
|
|
method: 'GET',
|
|
url: '/api/memory/frames?limit=1',
|
|
});
|
|
const frames = JSON.parse(listRes.body).results;
|
|
expect(frames.length).toBeGreaterThan(0);
|
|
const { id, accessCount: before } = frames[0];
|
|
const start = typeof before === 'number' ? before : 0;
|
|
|
|
// First call -> start + 1
|
|
const r1 = await injectWithAuth(server, { method: 'PATCH', url: `/api/memory/frames/${id}/access` });
|
|
expect(r1.statusCode).toBe(200);
|
|
const b1 = JSON.parse(r1.body);
|
|
expect(b1.accessed).toBe(true);
|
|
expect(b1.accessCount).toBe(start + 1);
|
|
expect(b1.mind).toBe('personal');
|
|
|
|
// Second call -> start + 2 (atomic)
|
|
const r2 = await injectWithAuth(server, { method: 'PATCH', url: `/api/memory/frames/${id}/access` });
|
|
expect(JSON.parse(r2.body).accessCount).toBe(start + 2);
|
|
});
|
|
|
|
it('PATCH on a missing frame returns 404', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'PATCH',
|
|
url: '/api/memory/frames/999999/access',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('PATCH with invalid id returns 400', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'PATCH',
|
|
url: '/api/memory/frames/not-a-number/access',
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
// --- Settings ---
|
|
describe('settings', () => {
|
|
it('reads default settings', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/settings' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.defaultModel).toBeDefined();
|
|
expect(body.dataDir).toBe(tmpDir);
|
|
});
|
|
|
|
it('updates and reads back settings', async () => {
|
|
// Update
|
|
const putRes = await injectWithAuth(server, {
|
|
method: 'PUT',
|
|
url: '/api/settings',
|
|
payload: { defaultModel: 'claude-opus-4-6' },
|
|
});
|
|
expect(putRes.statusCode).toBe(200);
|
|
const putBody = JSON.parse(putRes.body);
|
|
expect(putBody.defaultModel).toBe('claude-opus-4-6');
|
|
|
|
// Read back
|
|
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/settings' });
|
|
const getBody = JSON.parse(getRes.body);
|
|
expect(getBody.defaultModel).toBe('claude-opus-4-6');
|
|
});
|
|
});
|
|
});
|