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

View File

@@ -0,0 +1,126 @@
/**
* Auto-update configuration validation tests.
*
* Verifies the Tauri updater config, release workflow, and capability
* permissions are correctly set up for the auto-update flow.
*
* These tests validate static config only — no network calls.
*
* Release flow:
* git tag v1.0.1 && git push --tags
* This triggers release.yml which builds + signs + publishes to GitHub Releases.
* Tauri updater fetches latest.json from the release assets.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const ROOT = resolve(__dirname, '..');
const REPO_ROOT = resolve(ROOT, '..');
function readJson(relPath: string): unknown {
const content = readFileSync(resolve(ROOT, relPath), 'utf-8');
return JSON.parse(content);
}
function readText(relPath: string): string {
return readFileSync(resolve(REPO_ROOT, relPath), 'utf-8');
}
describe('auto-update configuration', () => {
describe('tauri.conf.json updater section', () => {
const config = readJson('src-tauri/tauri.conf.json') as {
plugins?: {
updater?: {
endpoints?: string[];
pubkey?: string;
};
};
};
// The updater plugin CONFIG was removed for v1 because release.yml published
// latest.json with EMPTY signatures — with a pubkey present, every client
// update would fail signature verification (a broken update channel). The
// dependency remains staged so it can be re-enabled once updater signing is
// provisioned (TAURI_SIGNING_PRIVATE_KEY + createUpdaterArtifacts + a
// real-signature latest.json generator), but runtime registration is off.
it('has the updater plugin config intentionally disabled for v1', () => {
expect(config.plugins?.updater).toBeUndefined();
});
});
describe('capabilities permissions', () => {
const caps = readJson('src-tauri/capabilities/default.json') as {
permissions?: string[];
};
it('does not expose updater commands while updater config is disabled', () => {
expect(caps.permissions).toBeDefined();
expect(caps.permissions).not.toContain('updater:default');
});
});
describe('Cargo.toml dependencies', () => {
const cargo = readFileSync(resolve(ROOT, 'src-tauri/Cargo.toml'), 'utf-8');
it('includes tauri-plugin-updater dependency', () => {
expect(cargo).toContain('tauri-plugin-updater');
});
});
describe('Rust updater plugin registration', () => {
const libRs = readFileSync(resolve(ROOT, 'src-tauri/src/lib.rs'), 'utf-8');
it('does not import UpdaterExt while updater config is disabled', () => {
expect(libRs).not.toContain('UpdaterExt');
});
it('does not register the updater plugin while updater config is disabled', () => {
expect(libRs).not.toContain('tauri_plugin_updater::Builder::new().build()');
});
it('does not run startup update checks while updater config is disabled', () => {
expect(libRs).not.toContain('.updater()');
});
});
describe('release workflow (release.yml)', () => {
const workflow = readText('.github/workflows/release.yml');
it('triggers on tag push', () => {
expect(workflow).toContain("- 'v*'");
});
it('builds for Windows', () => {
expect(workflow).toContain('windows-latest');
});
it('builds for macOS (both architectures)', () => {
expect(workflow).toContain('aarch64-apple-darwin');
expect(workflow).toContain('x86_64-apple-darwin');
});
it('uses tauri-action for builds', () => {
expect(workflow).toContain('tauri-apps/tauri-action');
});
it('does NOT publish a broken (empty-signature) updater manifest', () => {
// The update-manifest job was removed with the updater config (it published
// latest.json with empty signatures). Re-add it with real updater signing.
// A re-enable note in comments may still mention it — assert no active job.
expect(workflow).not.toMatch(/^\s*update-manifest:/m);
});
it('stages sidecar dependencies before packaging (P0-2)', () => {
// The packaged sidecar require()s esbuild-externalized deps that must be
// staged into resources/node_modules or it dies with MODULE_NOT_FOUND.
expect(workflow).toContain('stage-sidecar-deps');
});
});
// Frontend update hook describe block removed — the Tauri app/src/
// frontend was deprecated in favor of apps/web/ (see commit a883050).
// apps/web keeps a future `waggle://update-available` notice mapper, but
// native updater emission stays disabled until signed updater artifacts exist.
});

View File

@@ -0,0 +1,92 @@
/**
* AgentIntelligenceCard — tests that feedback stats are rendered correctly.
*
* Uses a lightweight approach: tests the data-transformation logic and
* verifies the component accepts the FeedbackStats shape without errors.
* Since this is a Tauri desktop app (not Next.js), we test the card's
* data contract and rendering expectations.
*/
import { describe, it, expect } from 'vitest';
import type { FeedbackStats } from '../src/components/cockpit/types';
describe('AgentIntelligenceCard data contract', () => {
it('FeedbackStats shape matches the /api/feedback/stats response', () => {
const stats: FeedbackStats = {
totalFeedback: 42,
positiveRate: 0.85,
topIssues: ['wrong_answer', 'too_verbose'],
correctionsThisWeek: 3,
improvementTrend: '+12%',
};
expect(stats.totalFeedback).toBe(42);
expect(stats.positiveRate).toBe(0.85);
expect(stats.topIssues).toHaveLength(2);
expect(stats.correctionsThisWeek).toBe(3);
expect(stats.improvementTrend).toBe('+12%');
});
it('handles empty stats (zero feedback)', () => {
const stats: FeedbackStats = {
totalFeedback: 0,
positiveRate: 0,
topIssues: [],
correctionsThisWeek: 0,
improvementTrend: '0%',
};
expect(stats.totalFeedback).toBe(0);
expect(stats.positiveRate).toBe(0);
expect(stats.topIssues).toEqual([]);
});
it('handles negative improvement trend', () => {
const stats: FeedbackStats = {
totalFeedback: 10,
positiveRate: 0.5,
topIssues: ['wrong_tool'],
correctionsThisWeek: 7,
improvementTrend: '-5%',
};
expect(stats.improvementTrend).toBe('-5%');
// Negative trend starts with '-'
expect(stats.improvementTrend.startsWith('-')).toBe(true);
});
it('trend parsing for positive values', () => {
const trend = '+12%';
const match = trend.match(/^([+-]?\d+)%$/);
expect(match).not.toBeNull();
expect(parseInt(match![1], 10)).toBe(12);
});
it('trend parsing for negative values', () => {
const trend = '-5%';
const match = trend.match(/^([+-]?\d+)%$/);
expect(match).not.toBeNull();
expect(parseInt(match![1], 10)).toBe(-5);
});
it('trend parsing for zero', () => {
const trend = '0%';
const match = trend.match(/^([+-]?\d+)%$/);
expect(match).not.toBeNull();
expect(parseInt(match![1], 10)).toBe(0);
});
it('reason formatting converts snake_case to Title Case', () => {
const formatReason = (reason: string): string =>
reason
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
expect(formatReason('wrong_answer')).toBe('Wrong Answer');
expect(formatReason('too_verbose')).toBe('Too Verbose');
expect(formatReason('wrong_tool')).toBe('Wrong Tool');
expect(formatReason('too_slow')).toBe('Too Slow');
expect(formatReason('other')).toBe('Other');
});
});

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

View File

@@ -0,0 +1,132 @@
/**
* E2E Tests: Startup, Onboarding, and Settings Persistence
*
* Scenarios covered:
* 1. Service starts and responds to health check
* 2. Onboarding wizard completes successfully (config save/load via settings API)
* 7. Settings saved and persisted across restart
*/
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 { 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('Startup & Settings 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 1: Service starts and responds to health check
it('health check returns 200 with status ok and mode local', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
const res = await server.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
// With skipLiteLLM, health is degraded (no verified LLM), not 'ok' — truthful health
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.database).toBeDefined();
});
// Scenario 2: Onboarding — save config via PUT, read it back via GET
it('onboarding: saves and loads config via settings API', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
const port = TEST_PORT;
const { server } = await startService({ dataDir, port, skipLiteLLM: true });
servers.push(server);
// Save onboarding config
const putRes = await injectWithAuth(server, {
method: 'PUT',
url: '/api/settings',
payload: {
defaultModel: 'anthropic/claude-sonnet-4-20250514',
providers: {
anthropic: { apiKey: 'sk-ant-test-key-1234567890', models: ['claude-sonnet-4-20250514'] },
},
},
});
expect(putRes.statusCode).toBe(200);
const putBody = JSON.parse(putRes.payload);
expect(putBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
// Read config back
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/settings' });
expect(getRes.statusCode).toBe(200);
const getBody = JSON.parse(getRes.payload);
expect(getBody.defaultModel).toBe('anthropic/claude-sonnet-4-20250514');
expect(getBody.providers).toBeDefined();
expect(getBody.dataDir).toBe(dataDir);
});
// Scenario 7: Settings persisted across restart
it('settings persist across server restart', async () => {
const dataDir = makeTmpDir();
tmpDirs.push(dataDir);
// --- First server: save settings ---
const port1 = TEST_PORT;
const { server: server1 } = await startService({ dataDir, port: port1, skipLiteLLM: true });
servers.push(server1);
const putRes = await injectWithAuth(server1, {
method: 'PUT',
url: '/api/settings',
payload: {
defaultModel: 'openai/gpt-4o',
providers: {
openai: { apiKey: 'sk-test-openai-key-1234567', models: ['gpt-4o'] },
},
},
});
expect(putRes.statusCode).toBe(200);
await server1.close();
servers.pop();
// --- Second server: read settings back ---
const port2 = TEST_PORT;
const { server: server2 } = await startService({ dataDir, port: port2, skipLiteLLM: true });
servers.push(server2);
const getRes = await injectWithAuth(server2, { method: 'GET', url: '/api/settings' });
expect(getRes.statusCode).toBe(200);
const body = JSON.parse(getRes.payload);
expect(body.defaultModel).toBe('openai/gpt-4o');
});
});

View File

@@ -0,0 +1,22 @@
/**
* Shared test utilities for e2e tests.
* Provides authenticated inject helper for local server tests (SEC-011).
*/
import type { FastifyInstance, InjectOptions } from 'fastify';
/**
* Shorthand: inject with auth token from the server's agentState.
* Returns the same result as server.inject().
*/
export function injectWithAuth(server: FastifyInstance, opts: InjectOptions) {
const token = server.agentState.wsSessionToken;
const existingHeaders = (opts.headers ?? {}) as Record<string, string>;
return server.inject({
...opts,
headers: {
...existingHeaders,
authorization: `Bearer ${token}`,
},
});
}

View File

@@ -0,0 +1,332 @@
/**
* 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);
});
});