Files
waggle-os/app/tests/e2e/workspaces.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

333 lines
12 KiB
TypeScript

/**
* E2E Tests: Workspaces, Memory Isolation, and Sessions
*
* Scenarios covered:
* 3. Workspace created via API
* 5. Workspace switching changes mind context
* 6. Memory search returns results from correct mind
* 8. Session management (create, list, delete)
*/
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 { FrameStore, SessionStore } from '@waggle/core';
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;
describe('Workspaces & Sessions 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 3: Create workspace via POST, verify GET returns it
it('creates a workspace and retrieves it', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Create workspace
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/workspaces',
payload: { name: 'Test Project', group: 'Work', icon: 'briefcase' },
});
expect(createRes.statusCode).toBe(201);
const created = JSON.parse(createRes.payload);
expect(created.name).toBe('Test Project');
expect(created.group).toBe('Work');
expect(created.id).toBeDefined();
// List workspaces — should contain the new one
const listRes = await injectWithAuth(server, { method: 'GET', url: '/api/workspaces' });
expect(listRes.statusCode).toBe(200);
const list = JSON.parse(listRes.payload);
expect(list).toBeInstanceOf(Array);
// Server now auto-creates a Default Workspace on boot via
// WorkspaceManager.ensureDefault(), so the list contains both
// the default and the one this test just created.
const testWs = list.find((w: { name: string }) => w.name === 'Test Project');
expect(testWs).toBeDefined();
// Get by ID
const getRes = await injectWithAuth(server, {
method: 'GET',
url: `/api/workspaces/${created.id}`,
});
expect(getRes.statusCode).toBe(200);
const fetched = JSON.parse(getRes.payload);
expect(fetched.id).toBe(created.id);
expect(fetched.icon).toBe('briefcase');
});
// Scenario 5: Workspace switching changes mind context
it('workspace switching isolates memory context', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Create two workspaces
const res1 = await injectWithAuth(server, {
method: 'POST',
url: '/api/workspaces',
payload: { name: 'Alpha', group: 'Work', model: 'openai/gpt-4o' },
});
const ws1 = JSON.parse(res1.payload);
const res2 = await injectWithAuth(server, {
method: 'POST',
url: '/api/workspaces',
payload: { name: 'Beta', group: 'Personal', model: 'anthropic/claude-sonnet-4-20250514' },
});
const ws2 = JSON.parse(res2.payload);
// Verify they are distinct
expect(ws1.id).not.toBe(ws2.id);
expect(ws1.group).toBe('Work');
expect(ws2.group).toBe('Personal');
// Each workspace has its own .mind file on disk
const mind1Path = path.join(dataDir, 'workspaces', ws1.id, 'workspace.mind');
const mind2Path = path.join(dataDir, 'workspaces', ws2.id, 'workspace.mind');
expect(fs.existsSync(mind1Path)).toBe(true);
expect(fs.existsSync(mind2Path)).toBe(true);
// Switch to ws-A, store memory, verify it's found
server.multiMind.switchWorkspace(mind1Path);
const wsASessions = new SessionStore(server.multiMind.workspace!);
const wsASession = wsASessions.create();
const wsAFrames = new FrameStore(server.multiMind.workspace!);
wsAFrames.createIFrame(wsASession.gop_id, 'Alpha project uses Kubernetes for deployment');
// Search ws-A scope — should find Kubernetes
const searchA = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Kubernetes&scope=workspace',
});
expect(searchA.statusCode).toBe(200);
const resultsA = JSON.parse(searchA.payload);
expect(resultsA.count).toBeGreaterThan(0);
// Switch to ws-B — search should NOT find ws-A's memory
server.multiMind.switchWorkspace(mind2Path);
const searchB = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Kubernetes&scope=workspace',
});
expect(searchB.statusCode).toBe(200);
const resultsB = JSON.parse(searchB.payload);
expect(resultsB.count).toBe(0);
// Store different memory in ws-B
const wsBSessions = new SessionStore(server.multiMind.workspace!);
const wsBSession = wsBSessions.create();
const wsBFrames = new FrameStore(server.multiMind.workspace!);
wsBFrames.createIFrame(wsBSession.gop_id, 'Beta project uses Docker Compose locally');
// ws-B should find Docker but not Kubernetes
const searchB2 = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Docker&scope=workspace',
});
expect(searchB2.statusCode).toBe(200);
expect(JSON.parse(searchB2.payload).count).toBeGreaterThan(0);
// Switch back to ws-A — should find Kubernetes, not Docker
server.multiMind.switchWorkspace(mind1Path);
const searchA2 = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Kubernetes&scope=workspace',
});
expect(searchA2.statusCode).toBe(200);
expect(JSON.parse(searchA2.payload).count).toBeGreaterThan(0);
const searchA3 = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Docker&scope=workspace',
});
expect(searchA3.statusCode).toBe(200);
expect(JSON.parse(searchA3.payload).count).toBe(0);
});
// Scenario 6: Memory search returns results from correct mind only
it('memory search returns results scoped to the correct mind', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Store a memory in the personal mind — need a session first (FK constraint)
const personalSessions = new SessionStore(server.multiMind.personal);
const pSession = personalSessions.create();
const personalFrames = new FrameStore(server.multiMind.personal);
personalFrames.createIFrame(pSession.gop_id, 'Waggle architecture uses Tauri with React frontend');
// Create a workspace and store memory in its mind
const createRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/workspaces',
payload: { name: 'Research', group: 'Study' },
});
const ws = JSON.parse(createRes.payload);
// Switch multiMind to workspace so workspace search works
const wsMindPath = path.join(dataDir, 'workspaces', ws.id, 'workspace.mind');
server.multiMind.switchWorkspace(wsMindPath);
// Store memory in workspace mind — need a session first (FK constraint)
const wsSessions = new SessionStore(server.multiMind.workspace!);
const wSession = wsSessions.create();
const wsFrames = new FrameStore(server.multiMind.workspace!);
wsFrames.createIFrame(wSession.gop_id, 'GraphContext uses SHACL validation for knowledge graphs');
// Search personal scope — should find "Tauri" but not "SHACL"
const personalSearch = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=Tauri&scope=personal',
});
expect(personalSearch.statusCode).toBe(200);
const personalResults = JSON.parse(personalSearch.payload);
expect(personalResults.count).toBeGreaterThan(0);
// Search workspace scope — should find "SHACL" but not "Tauri"
const wsSearch = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=SHACL&scope=workspace',
});
expect(wsSearch.statusCode).toBe(200);
const wsResults = JSON.parse(wsSearch.payload);
expect(wsResults.count).toBeGreaterThan(0);
// Cross-check: personal scope should NOT find workspace-only content
const crossCheck = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=SHACL&scope=personal',
});
const crossResults = JSON.parse(crossCheck.payload);
expect(crossResults.count).toBe(0);
// Search all scope — should find both
const allSearch = await injectWithAuth(server, {
method: 'GET',
url: '/api/memory/search?q=architecture&scope=all',
});
const allResults = JSON.parse(allSearch.payload);
expect(allResults.count).toBeGreaterThanOrEqual(1);
});
// Scenario 8: Session CRUD — create, list, rename (switch), delete
it('creates, lists, renames, and deletes sessions within a workspace', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Create workspace first
const wsRes = await injectWithAuth(server, {
method: 'POST',
url: '/api/workspaces',
payload: { name: 'Chat Workspace', group: 'Work' },
});
const ws = JSON.parse(wsRes.payload);
// Create a session
const createRes = await injectWithAuth(server, {
method: 'POST',
url: `/api/workspaces/${ws.id}/sessions`,
payload: { title: 'My First Chat' },
});
expect(createRes.statusCode).toBe(201);
const session = JSON.parse(createRes.payload);
expect(session.id).toBeDefined();
expect(session.title).toBe('My First Chat');
expect(session.messageCount).toBe(0);
// Create a second session
const createRes2 = await injectWithAuth(server, {
method: 'POST',
url: `/api/workspaces/${ws.id}/sessions`,
payload: { title: 'Debug Session' },
});
expect(createRes2.statusCode).toBe(201);
const session2 = JSON.parse(createRes2.payload);
// List sessions — should have 2
const listRes = await injectWithAuth(server, {
method: 'GET',
url: `/api/workspaces/${ws.id}/sessions`,
});
expect(listRes.statusCode).toBe(200);
const list = JSON.parse(listRes.payload);
expect(list.length).toBe(2);
// Rename (switch equivalent) — proves session is accessible and modifiable
const patchRes = await injectWithAuth(server, {
method: 'PATCH',
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
payload: { title: 'Renamed Chat' },
});
expect(patchRes.statusCode).toBe(200);
const patched = JSON.parse(patchRes.payload);
expect(patched.title).toBe('Renamed Chat');
expect(patched.id).toBe(session.id);
// Verify rename persisted in list
const listRes3 = await injectWithAuth(server, {
method: 'GET',
url: `/api/workspaces/${ws.id}/sessions`,
});
const list3 = JSON.parse(listRes3.payload);
const renamed = list3.find((s: { id: string }) => s.id === session.id);
expect(renamed).toBeDefined();
expect(renamed.title).toBe('Renamed Chat');
// Delete first session
const delRes = await injectWithAuth(server, {
method: 'DELETE',
url: `/api/sessions/${session.id}?workspace=${ws.id}`,
});
expect(delRes.statusCode).toBe(200);
const delBody = JSON.parse(delRes.payload);
expect(delBody.deleted).toBe(true);
// List again — should have 1
const listRes2 = await injectWithAuth(server, {
method: 'GET',
url: `/api/workspaces/${ws.id}/sessions`,
});
const list2 = JSON.parse(listRes2.payload);
expect(list2.length).toBe(1);
expect(list2[0].id).toBe(session2.id);
});
});