Files
waggle-os/packages/server/tests/local-mode.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

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');
});
});
});