mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
Add setup detection module and comprehensive setup scenario tests
Extract testable detection logic from setup.mjs into lib/setup-detection.ts and add 59 tests covering fresh, partial, existing, and transition user scenarios across all subsystems (agents, memory, crons, pipelines, env). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b3362863d4
commit
91cad2dd15
@@ -0,0 +1,416 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Tests for setup detection helpers.
|
||||
*
|
||||
* These simulate the detection logic that runs during `npm run setup`
|
||||
* and `clawport doctor`, covering:
|
||||
* - Fresh user: nothing installed
|
||||
* - Partial user: OpenClaw installed but not fully configured
|
||||
* - Existing user: everything configured and running
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
// ── Computed paths (using real homedir) ───────────────────────────
|
||||
|
||||
const HOME = homedir()
|
||||
const OPENCLAW_DIR = join(HOME, '.openclaw')
|
||||
const WORKSPACE_PATH = join(OPENCLAW_DIR, 'workspace')
|
||||
const CONFIG_PATH = join(OPENCLAW_DIR, 'openclaw.json')
|
||||
|
||||
// ── Hoisted mocks ─────────────────────────────────────────────────
|
||||
|
||||
const { mockExistsSync, mockReadFileSync, mockWriteFileSync } = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn(),
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockWriteFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockExecSync } = vi.hoisted(() => ({
|
||||
mockExecSync: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
default: {
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: mockExecSync,
|
||||
default: { execSync: mockExecSync },
|
||||
}))
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────
|
||||
|
||||
import {
|
||||
detectWorkspacePath,
|
||||
detectOpenClawBin,
|
||||
detectGatewayToken,
|
||||
checkHttpEndpointEnabled,
|
||||
enableHttpEndpoint,
|
||||
detectAll,
|
||||
} from './setup-detection'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// detectWorkspacePath
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('detectWorkspacePath', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns null when ~/.openclaw/workspace does not exist', () => {
|
||||
expect(detectWorkspacePath()).toBeNull()
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(WORKSPACE_PATH)
|
||||
})
|
||||
|
||||
it('returns path when ~/.openclaw/workspace exists', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === WORKSPACE_PATH)
|
||||
expect(detectWorkspacePath()).toBe(WORKSPACE_PATH)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// detectOpenClawBin
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('detectOpenClawBin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when openclaw is not on PATH', () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('not found')
|
||||
})
|
||||
expect(detectOpenClawBin()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns binary path when openclaw is found', () => {
|
||||
mockExecSync.mockReturnValue('/usr/local/bin/openclaw\n')
|
||||
expect(detectOpenClawBin()).toBe('/usr/local/bin/openclaw')
|
||||
})
|
||||
|
||||
it('trims whitespace from result', () => {
|
||||
mockExecSync.mockReturnValue(' /opt/openclaw/bin/openclaw \n')
|
||||
expect(detectOpenClawBin()).toBe('/opt/openclaw/bin/openclaw')
|
||||
})
|
||||
|
||||
it('uses "which" on non-Windows platforms', () => {
|
||||
mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
|
||||
detectOpenClawBin()
|
||||
// On macOS/Linux, should use "which"
|
||||
if (process.platform !== 'win32') {
|
||||
expect(mockExecSync).toHaveBeenCalledWith(
|
||||
'which openclaw',
|
||||
expect.objectContaining({ encoding: 'utf-8' })
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// detectGatewayToken
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('detectGatewayToken', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns null when openclaw.json does not exist', () => {
|
||||
expect(detectGatewayToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns token from openclaw.json', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 'oc_tok_abc123xyz789',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(detectGatewayToken()).toBe('oc_tok_abc123xyz789')
|
||||
})
|
||||
|
||||
it('returns null when gateway.auth.token is missing', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
http: { port: 18789 },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(detectGatewayToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when token is not a string', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
auth: { token: 12345 },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(detectGatewayToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when openclaw.json is malformed', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue('not json')
|
||||
|
||||
expect(detectGatewayToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when readFileSync throws', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('EACCES')
|
||||
})
|
||||
|
||||
expect(detectGatewayToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// checkHttpEndpointEnabled
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('checkHttpEndpointEnabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns null when openclaw.json does not exist', () => {
|
||||
expect(checkHttpEndpointEnabled()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns true when chatCompletions endpoint is enabled', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(checkHttpEndpointEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when chatCompletions endpoint is disabled', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(checkHttpEndpointEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when endpoints key is missing', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: { http: {} },
|
||||
}))
|
||||
|
||||
expect(checkHttpEndpointEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when chatCompletions key is missing', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
http: { endpoints: {} },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(checkHttpEndpointEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null for malformed JSON', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue('{broken}')
|
||||
|
||||
expect(checkHttpEndpointEnabled()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// enableHttpEndpoint
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('enableHttpEndpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns false when openclaw.json does not exist', () => {
|
||||
expect(enableHttpEndpoint()).toBe(false)
|
||||
})
|
||||
|
||||
it('enables the endpoint and writes back to file', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: { auth: { token: 'test' } },
|
||||
}))
|
||||
|
||||
const result = enableHttpEndpoint()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockWriteFileSync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify the written content has the endpoint enabled
|
||||
const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
|
||||
const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
|
||||
expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
|
||||
// Preserves existing config
|
||||
expect(parsed.gateway.auth.token).toBe('test')
|
||||
})
|
||||
|
||||
it('creates nested structure when gateway.http is missing', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({ gateway: {} }))
|
||||
|
||||
const result = enableHttpEndpoint()
|
||||
expect(result).toBe(true)
|
||||
|
||||
const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
|
||||
const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
|
||||
expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('creates gateway key when missing entirely', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({ version: 1 }))
|
||||
|
||||
const result = enableHttpEndpoint()
|
||||
expect(result).toBe(true)
|
||||
|
||||
const writtenContent = mockWriteFileSync.mock.calls[0][1] as string
|
||||
const parsed = JSON.parse(writtenContent.replace(/\n$/, ''))
|
||||
expect(parsed.version).toBe(1) // preserves existing
|
||||
expect(parsed.gateway.http.endpoints.chatCompletions.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when writeFileSync throws', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({}))
|
||||
mockWriteFileSync.mockImplementation(() => {
|
||||
throw new Error('EACCES')
|
||||
})
|
||||
|
||||
expect(enableHttpEndpoint()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when readFileSync throws', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => p === CONFIG_PATH)
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('EACCES')
|
||||
})
|
||||
|
||||
expect(enableHttpEndpoint()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// detectAll — integration
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('detectAll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('returns all nulls for a completely fresh system', () => {
|
||||
const result = detectAll()
|
||||
expect(result.workspacePath).toBeNull()
|
||||
expect(result.openclawBin).toBeNull()
|
||||
expect(result.gatewayToken).toBeNull()
|
||||
expect(result.httpEndpointEnabled).toBeNull()
|
||||
})
|
||||
|
||||
it('returns all values for a fully configured system', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === WORKSPACE_PATH) return true
|
||||
if (p === CONFIG_PATH) return true
|
||||
return false
|
||||
})
|
||||
mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
auth: { token: 'oc_tok_test123' },
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const result = detectAll()
|
||||
expect(result.workspacePath).toBe(WORKSPACE_PATH)
|
||||
expect(result.openclawBin).toBe('/usr/local/bin/openclaw')
|
||||
expect(result.gatewayToken).toBe('oc_tok_test123')
|
||||
expect(result.httpEndpointEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('handles partial setup (workspace exists, no binary)', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === WORKSPACE_PATH) return true
|
||||
if (p === CONFIG_PATH) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
gateway: {
|
||||
auth: { token: 'oc_tok_partial' },
|
||||
},
|
||||
}))
|
||||
|
||||
const result = detectAll()
|
||||
expect(result.workspacePath).toBe(WORKSPACE_PATH)
|
||||
expect(result.openclawBin).toBeNull()
|
||||
expect(result.gatewayToken).toBe('oc_tok_partial')
|
||||
expect(result.httpEndpointEnabled).toBe(false) // endpoint key missing
|
||||
})
|
||||
|
||||
it('handles workspace without config file', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === WORKSPACE_PATH) return true
|
||||
return false
|
||||
})
|
||||
mockExecSync.mockReturnValue('/usr/local/bin/openclaw')
|
||||
|
||||
const result = detectAll()
|
||||
expect(result.workspacePath).toBe(WORKSPACE_PATH)
|
||||
expect(result.openclawBin).toBe('/usr/local/bin/openclaw')
|
||||
expect(result.gatewayToken).toBeNull()
|
||||
expect(result.httpEndpointEnabled).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Setup detection helpers — extracted from scripts/setup.mjs for testability.
|
||||
*
|
||||
* These functions detect the user's OpenClaw installation state:
|
||||
* - Workspace directory location
|
||||
* - Gateway auth token from openclaw.json
|
||||
* - HTTP endpoint configuration
|
||||
* - OpenClaw binary on PATH
|
||||
*
|
||||
* Used by: npm run setup, clawport setup, clawport doctor
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
// ── Workspace ─────────────────────────────────────────────────────
|
||||
|
||||
/** Detect the default workspace path (~/.openclaw/workspace). */
|
||||
export function detectWorkspacePath(): string | null {
|
||||
const defaultPath = join(homedir(), '.openclaw', 'workspace')
|
||||
if (existsSync(defaultPath)) return defaultPath
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Binary ────────────────────────────────────────────────────────
|
||||
|
||||
/** Find the openclaw binary on PATH. */
|
||||
export function detectOpenClawBin(): string | null {
|
||||
const cmd = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
return execSync(`${cmd} openclaw`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway Token ─────────────────────────────────────────────────
|
||||
|
||||
/** Read the gateway auth token from ~/.openclaw/openclaw.json. */
|
||||
export function detectGatewayToken(): string | null {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
|
||||
if (!existsSync(configPath)) return null
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||
const token = config?.gateway?.auth?.token
|
||||
return typeof token === 'string' ? token : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP Endpoint ─────────────────────────────────────────────────
|
||||
|
||||
/** Check if the HTTP chat completions endpoint is enabled in openclaw.json. */
|
||||
export function checkHttpEndpointEnabled(): boolean | null {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
|
||||
if (!existsSync(configPath)) return null
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||
return config?.gateway?.http?.endpoints?.chatCompletions?.enabled === true
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable the HTTP chat completions endpoint in openclaw.json. */
|
||||
export function enableHttpEndpoint(): boolean {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
|
||||
if (!existsSync(configPath)) return false
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||
if (!config.gateway) config.gateway = {}
|
||||
if (!config.gateway.http) config.gateway.http = {}
|
||||
if (!config.gateway.http.endpoints) config.gateway.http.endpoints = {}
|
||||
if (!config.gateway.http.endpoints.chatCompletions) config.gateway.http.endpoints.chatCompletions = {}
|
||||
config.gateway.http.endpoints.chatCompletions.enabled = true
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
|
||||
export interface DetectionResult {
|
||||
workspacePath: string | null
|
||||
openclawBin: string | null
|
||||
gatewayToken: string | null
|
||||
httpEndpointEnabled: boolean | null
|
||||
}
|
||||
|
||||
/** Run all detectors and return a summary. */
|
||||
export function detectAll(): DetectionResult {
|
||||
return {
|
||||
workspacePath: detectWorkspacePath(),
|
||||
openclawBin: detectOpenClawBin(),
|
||||
gatewayToken: detectGatewayToken(),
|
||||
httpEndpointEnabled: checkHttpEndpointEnabled(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Setup Scenarios — Tests how ClawPort behaves for fresh users vs existing users.
|
||||
*
|
||||
* These tests simulate the full resolution chain for each subsystem:
|
||||
* - Agent registry (bundled fallback → auto-discovery → user override)
|
||||
* - Memory (empty workspace → populated workspace)
|
||||
* - Crons (CLI unavailable → CLI returns data)
|
||||
* - Pipelines (no config → pipelines.json present)
|
||||
* - Environment (missing vars → all vars present)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ── Hoisted mocks ─────────────────────────────────────────────────
|
||||
|
||||
const { mockReadFileSync, mockExistsSync, mockStatSync, mockReaddirSync } = vi.hoisted(() => ({
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
mockStatSync: vi.fn(),
|
||||
mockReaddirSync: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockExecSync } = vi.hoisted(() => ({
|
||||
mockExecSync: vi.fn(),
|
||||
}))
|
||||
|
||||
const { bundledAgents } = vi.hoisted(() => ({
|
||||
bundledAgents: [
|
||||
{
|
||||
id: 'example',
|
||||
name: 'Example',
|
||||
title: 'Demo Agent',
|
||||
reportsTo: null,
|
||||
directReports: [],
|
||||
soulPath: 'SOUL.md',
|
||||
voiceId: null,
|
||||
color: '#f5c518',
|
||||
emoji: 'E',
|
||||
tools: ['read', 'write'],
|
||||
memoryPath: null,
|
||||
description: 'Bundled example agent.',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: mockReadFileSync,
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStatSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
default: {
|
||||
readFileSync: mockReadFileSync,
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStatSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: mockExecSync,
|
||||
default: { execSync: mockExecSync },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agents.json', () => ({
|
||||
default: bundledAgents,
|
||||
}))
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────
|
||||
|
||||
import { getAgents } from './agents'
|
||||
import { loadRegistry } from './agents-registry'
|
||||
import { getMemoryFiles, getMemoryConfig, getMemoryStatus, computeMemoryStats } from './memory'
|
||||
import { requireEnv } from './env'
|
||||
import { loadPipelines } from './cron-pipelines.server'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const WS = '/home/user/.openclaw/workspace'
|
||||
|
||||
function fakeStat(size: number, mtime?: Date) {
|
||||
return {
|
||||
size,
|
||||
mtime: mtime ?? new Date('2026-03-01T12:00:00Z'),
|
||||
isFile: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SCENARIO 1: FRESH USER — No OpenClaw installation at all
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Fresh user (no OpenClaw installed)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
// Fresh user: no env vars set, no files on disk
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockReaddirSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('environment', () => {
|
||||
it('requireEnv throws for WORKSPACE_PATH', () => {
|
||||
expect(() => requireEnv('WORKSPACE_PATH')).toThrow('Missing required environment variable')
|
||||
})
|
||||
|
||||
it('requireEnv throws for OPENCLAW_BIN', () => {
|
||||
expect(() => requireEnv('OPENCLAW_BIN')).toThrow('Missing required environment variable')
|
||||
})
|
||||
|
||||
it('requireEnv throws for OPENCLAW_GATEWAY_TOKEN', () => {
|
||||
expect(() => requireEnv('OPENCLAW_GATEWAY_TOKEN')).toThrow('Missing required environment variable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent registry', () => {
|
||||
it('falls back to bundled agents when no WORKSPACE_PATH', async () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
const agents = await getAgents()
|
||||
expect(agents.length).toBe(bundledAgents.length)
|
||||
expect(agents[0].id).toBe('example')
|
||||
expect(agents[0].name).toBe('Example')
|
||||
})
|
||||
|
||||
it('bundled agents have soul=null (no workspace to read from)', async () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
const agents = await getAgents()
|
||||
for (const agent of agents) {
|
||||
expect(agent.soul).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('bundled agents have empty crons array', async () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
const agents = await getAgents()
|
||||
for (const agent of agents) {
|
||||
expect(agent.crons).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory', () => {
|
||||
it('getMemoryFiles throws without WORKSPACE_PATH', async () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
await expect(getMemoryFiles()).rejects.toThrow('Missing required environment variable')
|
||||
})
|
||||
|
||||
it('getMemoryConfig throws without WORKSPACE_PATH', () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
expect(() => getMemoryConfig()).toThrow('Missing required environment variable')
|
||||
})
|
||||
|
||||
it('getMemoryStatus returns defaults without OPENCLAW_BIN', () => {
|
||||
vi.stubEnv('OPENCLAW_BIN', '')
|
||||
const status = getMemoryStatus()
|
||||
expect(status.indexed).toBe(false)
|
||||
expect(status.raw).toBe('Memory status unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pipelines', () => {
|
||||
it('loadPipelines returns empty array without WORKSPACE_PATH', () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
const pipelines = loadPipelines()
|
||||
expect(pipelines).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SCENARIO 2: PARTIAL USER — OpenClaw installed, setup not run yet
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Partial user (OpenClaw installed, no ClawPort config)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.stubEnv('WORKSPACE_PATH', WS)
|
||||
vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
|
||||
vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', 'tok_test_12345')
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockReaddirSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('agent registry — empty workspace', () => {
|
||||
it('no agents/ dir and no SOUL.md → uses bundled fallback', async () => {
|
||||
const agents = await getAgents()
|
||||
expect(agents.length).toBe(bundledAgents.length)
|
||||
expect(agents[0].id).toBe('example')
|
||||
})
|
||||
|
||||
it('empty agents/ dir → uses bundled fallback', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/agents`) return true
|
||||
return false
|
||||
})
|
||||
mockReaddirSync.mockReturnValue([])
|
||||
|
||||
const registry = loadRegistry()
|
||||
expect(registry.length).toBe(bundledAgents.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent registry — workspace with only root SOUL.md', () => {
|
||||
it('root SOUL.md only → discovers single root orchestrator', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return false
|
||||
if (p === `${WS}/SOUL.md`) return true
|
||||
if (p === `${WS}/IDENTITY.md`) return false
|
||||
if (p === `${WS}/agents`) return true
|
||||
return false
|
||||
})
|
||||
mockReaddirSync.mockReturnValue([])
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/SOUL.md`) return '# SOUL.md — MyAssistant\nI am helpful.'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const agents = await getAgents()
|
||||
expect(agents.length).toBe(1)
|
||||
expect(agents[0].name).toBe('MyAssistant')
|
||||
expect(agents[0].title).toBe('Orchestrator')
|
||||
expect(agents[0].reportsTo).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory — empty workspace', () => {
|
||||
it('no MEMORY.md and no memory/ dir → returns empty array', async () => {
|
||||
const files = await getMemoryFiles()
|
||||
expect(files).toEqual([])
|
||||
})
|
||||
|
||||
it('computeMemoryStats on empty files → all zeros', () => {
|
||||
const stats = computeMemoryStats([])
|
||||
expect(stats.totalFiles).toBe(0)
|
||||
expect(stats.totalSizeBytes).toBe(0)
|
||||
expect(stats.dailyLogCount).toBe(0)
|
||||
expect(stats.evergreenCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory config — no openclaw.json', () => {
|
||||
it('returns defaults with configFound=false', () => {
|
||||
const config = getMemoryConfig()
|
||||
expect(config.configFound).toBe(false)
|
||||
expect(config.memorySearch.enabled).toBe(false)
|
||||
expect(config.memorySearch.hybrid.vectorWeight).toBe(0.7)
|
||||
expect(config.memorySearch.hybrid.temporalDecay.halfLifeDays).toBe(30)
|
||||
expect(config.memoryFlush.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory status — CLI not responding', () => {
|
||||
it('handles CLI command failure gracefully', () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found: openclaw')
|
||||
})
|
||||
const status = getMemoryStatus()
|
||||
expect(status.indexed).toBe(false)
|
||||
expect(status.raw).toBe('Memory status unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pipelines — no pipelines.json', () => {
|
||||
it('returns empty array when pipelines.json does not exist', () => {
|
||||
const pipelines = loadPipelines()
|
||||
expect(pipelines).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SCENARIO 3: EXISTING USER — Fully configured workspace
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Existing user (fully configured workspace)', () => {
|
||||
const OPENCLAW_BIN = '/usr/local/bin/openclaw'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.stubEnv('WORKSPACE_PATH', WS)
|
||||
vi.stubEnv('OPENCLAW_BIN', OPENCLAW_BIN)
|
||||
vi.stubEnv('OPENCLAW_GATEWAY_TOKEN', 'oc_tok_abc123xyz')
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockReaddirSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('agent registry — user override (clawport/agents.json)', () => {
|
||||
const customAgents = [
|
||||
{
|
||||
id: 'orchestrator',
|
||||
name: 'Commander',
|
||||
title: 'Lead Agent',
|
||||
reportsTo: null,
|
||||
directReports: ['worker-a', 'worker-b'],
|
||||
soulPath: 'SOUL.md',
|
||||
voiceId: null,
|
||||
color: '#e11d48',
|
||||
emoji: 'C',
|
||||
tools: ['exec', 'read', 'write', 'message'],
|
||||
memoryPath: null,
|
||||
description: 'Top-level orchestrator.',
|
||||
},
|
||||
{
|
||||
id: 'worker-a',
|
||||
name: 'Alpha',
|
||||
title: 'Research Agent',
|
||||
reportsTo: 'orchestrator',
|
||||
directReports: [],
|
||||
soulPath: 'agents/alpha/SOUL.md',
|
||||
voiceId: null,
|
||||
color: '#3b82f6',
|
||||
emoji: 'A',
|
||||
tools: ['web_search', 'read'],
|
||||
memoryPath: null,
|
||||
description: 'Research specialist.',
|
||||
},
|
||||
{
|
||||
id: 'worker-b',
|
||||
name: 'Beta',
|
||||
title: 'Writer Agent',
|
||||
reportsTo: 'orchestrator',
|
||||
directReports: [],
|
||||
soulPath: 'agents/beta/SOUL.md',
|
||||
voiceId: null,
|
||||
color: '#22c55e',
|
||||
emoji: 'B',
|
||||
tools: ['read', 'write'],
|
||||
memoryPath: null,
|
||||
description: 'Content writer.',
|
||||
},
|
||||
]
|
||||
|
||||
it('loads custom agents from workspace override', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return true
|
||||
if (p.endsWith('SOUL.md')) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
|
||||
if (p.endsWith('SOUL.md')) return '# Agent SOUL content\nI do things.'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const agents = await getAgents()
|
||||
expect(agents.length).toBe(3)
|
||||
expect(agents.map(a => a.id)).toEqual(['orchestrator', 'worker-a', 'worker-b'])
|
||||
})
|
||||
|
||||
it('reads SOUL.md content for each agent', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return true
|
||||
if (p.endsWith('SOUL.md')) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
|
||||
if (p === `${WS}/SOUL.md`) return '# Commander\nI orchestrate.'
|
||||
if (p === `${WS}/agents/alpha/SOUL.md`) return '# ALPHA\nI research.'
|
||||
if (p === `${WS}/agents/beta/SOUL.md`) return '# BETA\nI write.'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const agents = await getAgents()
|
||||
const cmd = agents.find(a => a.id === 'orchestrator')!
|
||||
expect(cmd.soul).toBe('# Commander\nI orchestrate.')
|
||||
const alpha = agents.find(a => a.id === 'worker-a')!
|
||||
expect(alpha.soul).toBe('# ALPHA\nI research.')
|
||||
})
|
||||
|
||||
it('preserves hierarchy structure', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const agents = await getAgents()
|
||||
const root = agents.find(a => a.reportsTo === null)!
|
||||
expect(root.id).toBe('orchestrator')
|
||||
expect(root.directReports).toEqual(['worker-a', 'worker-b'])
|
||||
|
||||
const workerA = agents.find(a => a.id === 'worker-a')!
|
||||
expect(workerA.reportsTo).toBe('orchestrator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent registry — auto-discovery (no agents.json, has agents/ dir)', () => {
|
||||
it('discovers full hierarchy with root + agents + sub-agents', async () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return false
|
||||
if (p === `${WS}/SOUL.md`) return true
|
||||
if (p === `${WS}/IDENTITY.md`) return true
|
||||
if (p === `${WS}/agents`) return true
|
||||
if (p === `${WS}/agents/research/SOUL.md`) return true
|
||||
if (p === `${WS}/agents/research/sub-agents`) return true
|
||||
if (p === `${WS}/agents/research/sub-agents/SCOUT.md`) return true
|
||||
if (p === `${WS}/agents/research/members`) return false
|
||||
if (p === `${WS}/agents/content/SOUL.md`) return true
|
||||
if (p === `${WS}/agents/content/sub-agents`) return false
|
||||
if (p === `${WS}/agents/content/members`) return true
|
||||
if (p === `${WS}/agents/content/members/WRITER.md`) return true
|
||||
return false
|
||||
})
|
||||
|
||||
mockReaddirSync.mockImplementation((p: string | { toString(): string }, opts?: unknown) => {
|
||||
const path = typeof p === 'string' ? p : p.toString()
|
||||
if (path === `${WS}/agents`) {
|
||||
return [
|
||||
{ name: 'research', isDirectory: () => true },
|
||||
{ name: 'content', isDirectory: () => true },
|
||||
]
|
||||
}
|
||||
if (path === `${WS}/agents/research/sub-agents`) return ['SCOUT.md']
|
||||
if (path === `${WS}/agents/content/members`) return ['WRITER.md']
|
||||
return []
|
||||
})
|
||||
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/IDENTITY.md`) return '- **Name:** Atlas\n- **Emoji:** 🌐'
|
||||
if (p === `${WS}/SOUL.md`) return '# SOUL.md - Who You Are\nYou are Atlas.'
|
||||
if (p === `${WS}/agents/research/SOUL.md`) return '# SOUL.md — VERA, Chief Strategy Officer'
|
||||
if (p === `${WS}/agents/content/SOUL.md`) return '# HERALD — Content Director'
|
||||
if (p === `${WS}/agents/research/sub-agents/SCOUT.md`) return '# SCOUT — Trend Finder'
|
||||
if (p === `${WS}/agents/content/members/WRITER.md`) return '# QUILL — Blog Writer'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const agents = await getAgents()
|
||||
|
||||
// Root agent from IDENTITY.md
|
||||
const root = agents.find(a => a.reportsTo === null)!
|
||||
expect(root.name).toBe('Atlas')
|
||||
expect(root.emoji).toBe('🌐')
|
||||
expect(root.id).toBe('atlas')
|
||||
|
||||
// Research agent
|
||||
const vera = agents.find(a => a.id === 'research')!
|
||||
expect(vera.name).toBe('VERA')
|
||||
expect(vera.title).toBe('Chief Strategy Officer')
|
||||
expect(vera.reportsTo).toBe('atlas')
|
||||
|
||||
// Content agent
|
||||
const herald = agents.find(a => a.id === 'content')!
|
||||
expect(herald.name).toBe('HERALD')
|
||||
expect(herald.title).toBe('Content Director')
|
||||
expect(herald.reportsTo).toBe('atlas')
|
||||
|
||||
// Sub-agents
|
||||
const scout = agents.find(a => a.id === 'research-scout')!
|
||||
expect(scout.name).toBe('SCOUT')
|
||||
expect(scout.title).toBe('Trend Finder')
|
||||
expect(scout.reportsTo).toBe('research')
|
||||
|
||||
// Sub-agent ID is derived from filename (WRITER.md → content-writer)
|
||||
const writer = agents.find(a => a.id === 'content-writer')!
|
||||
expect(writer.name).toBe('QUILL') // name from heading, not filename
|
||||
expect(writer.title).toBe('Blog Writer')
|
||||
expect(writer.reportsTo).toBe('content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory — populated workspace', () => {
|
||||
it('discovers all memory files from root and memory/ dir', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/MEMORY.md`) return true
|
||||
if (p === `${WS}/memory`) return true
|
||||
return false
|
||||
})
|
||||
mockReaddirSync.mockReturnValue([
|
||||
`${today}.md`,
|
||||
`${yesterday}.md`,
|
||||
'team-memory.md',
|
||||
'debugging.md',
|
||||
])
|
||||
mockReadFileSync.mockReturnValue('# Content')
|
||||
mockStatSync.mockReturnValue(fakeStat(512))
|
||||
|
||||
const files = await getMemoryFiles()
|
||||
|
||||
// 1 root MEMORY.md + 4 memory/ files = 5
|
||||
expect(files.length).toBe(5)
|
||||
|
||||
// Evergreen files sorted first
|
||||
const categories = files.map(f => f.category)
|
||||
const firstDailyIdx = categories.indexOf('daily')
|
||||
const lastEvergreenIdx = categories.lastIndexOf('evergreen')
|
||||
expect(lastEvergreenIdx).toBeLessThan(firstDailyIdx)
|
||||
|
||||
// Root memory has special label
|
||||
const rootMemory = files.find(f => f.relativePath === 'MEMORY.md')
|
||||
expect(rootMemory?.label).toBe('Long-Term Memory')
|
||||
|
||||
// Today's log has special label
|
||||
const todayLog = files.find(f => f.relativePath === `memory/${today}.md`)
|
||||
expect(todayLog?.label).toBe('Daily Log (Today)')
|
||||
|
||||
// Yesterday's log has special label
|
||||
const yesterdayLog = files.find(f => f.relativePath === `memory/${yesterday}.md`)
|
||||
expect(yesterdayLog?.label).toBe('Daily Log (Yesterday)')
|
||||
|
||||
// Evergreen files use humanized names
|
||||
const teamMemory = files.find(f => f.relativePath === 'memory/team-memory.md')
|
||||
expect(teamMemory?.label).toBe('Team Memory')
|
||||
expect(teamMemory?.category).toBe('evergreen')
|
||||
})
|
||||
|
||||
it('computeMemoryStats reflects full workspace', () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const files = [
|
||||
{ label: 'LTM', path: `${WS}/MEMORY.md`, relativePath: 'MEMORY.md', content: '# Memory', lastModified: '2026-03-01T12:00:00Z', sizeBytes: 2048, category: 'evergreen' as const },
|
||||
{ label: 'Team', path: `${WS}/memory/team-memory.md`, relativePath: 'memory/team-memory.md', content: '', lastModified: '2026-03-01T12:00:00Z', sizeBytes: 1024, category: 'evergreen' as const },
|
||||
{ label: 'Today', path: `${WS}/memory/${today}.md`, relativePath: `memory/${today}.md`, content: '', lastModified: `${today}T12:00:00Z`, sizeBytes: 256, category: 'daily' as const },
|
||||
{ label: 'Yesterday', path: `${WS}/memory/2026-03-03.md`, relativePath: 'memory/2026-03-03.md', content: '', lastModified: '2026-03-03T12:00:00Z', sizeBytes: 384, category: 'daily' as const },
|
||||
]
|
||||
|
||||
const stats = computeMemoryStats(files)
|
||||
expect(stats.totalFiles).toBe(4)
|
||||
expect(stats.totalSizeBytes).toBe(2048 + 1024 + 256 + 384)
|
||||
expect(stats.evergreenCount).toBe(2)
|
||||
expect(stats.dailyLogCount).toBe(2)
|
||||
expect(stats.dailyTimeline).toHaveLength(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory config — openclaw.json with memory search enabled', () => {
|
||||
it('reads and merges memory search config', () => {
|
||||
// openclaw.json is in the parent of the workspace dir
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-small',
|
||||
hybrid: {
|
||||
enabled: true,
|
||||
vectorWeight: 0.8,
|
||||
textWeight: 0.2,
|
||||
temporalDecay: { enabled: true, halfLifeDays: 14 },
|
||||
mmr: { enabled: true, lambda: 0.6 },
|
||||
},
|
||||
cache: { enabled: true, maxEntries: 512 },
|
||||
},
|
||||
compaction: {
|
||||
memoryFlush: { enabled: true, softThresholdTokens: 60000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const config = getMemoryConfig()
|
||||
expect(config.configFound).toBe(true)
|
||||
expect(config.memorySearch.enabled).toBe(true)
|
||||
expect(config.memorySearch.provider).toBe('openai')
|
||||
expect(config.memorySearch.model).toBe('text-embedding-3-small')
|
||||
expect(config.memorySearch.hybrid.vectorWeight).toBe(0.8)
|
||||
expect(config.memorySearch.hybrid.temporalDecay.halfLifeDays).toBe(14)
|
||||
expect(config.memorySearch.hybrid.mmr.lambda).toBe(0.6)
|
||||
expect(config.memorySearch.cache.maxEntries).toBe(512)
|
||||
expect(config.memoryFlush.enabled).toBe(true)
|
||||
expect(config.memoryFlush.softThresholdTokens).toBe(60000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory status — CLI returns JSON', () => {
|
||||
it('parses full memory status from CLI', () => {
|
||||
vi.stubEnv('OPENCLAW_BIN', OPENCLAW_BIN)
|
||||
mockExecSync.mockReturnValue(JSON.stringify({
|
||||
indexed: true,
|
||||
lastIndexed: '2026-03-04T08:00:00Z',
|
||||
totalEntries: 128,
|
||||
vectorAvailable: true,
|
||||
embeddingProvider: 'openai',
|
||||
}))
|
||||
|
||||
const status = getMemoryStatus()
|
||||
expect(status.indexed).toBe(true)
|
||||
expect(status.lastIndexed).toBe('2026-03-04T08:00:00Z')
|
||||
expect(status.totalEntries).toBe(128)
|
||||
expect(status.vectorAvailable).toBe(true)
|
||||
expect(status.embeddingProvider).toBe('openai')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pipelines — pipelines.json configured', () => {
|
||||
it('loads pipeline definitions from workspace', () => {
|
||||
const pipelineData = [
|
||||
{
|
||||
name: 'daily-report',
|
||||
edges: [
|
||||
{ from: 'scout-daily', to: 'vera-daily-review', artifact: 'scout-report.md' },
|
||||
{ from: 'vera-daily-review', to: 'herald-publish', artifact: 'reviewed-report.md' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/pipelines.json`) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/pipelines.json`) return JSON.stringify(pipelineData)
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const pipelines = loadPipelines()
|
||||
expect(pipelines).toHaveLength(1)
|
||||
expect(pipelines[0].name).toBe('daily-report')
|
||||
expect(pipelines[0].edges).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles malformed pipelines.json gracefully', () => {
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/pipelines.json`) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/pipelines.json`) return '{ broken json'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const pipelines = loadPipelines()
|
||||
expect(pipelines).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SCENARIO 4: TRANSITION — User going from fresh to configured
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Transition scenarios', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('agents upgrade from bundled → auto-discovery when SOUL.md appears', async () => {
|
||||
// Phase 1: No workspace → bundled
|
||||
vi.stubEnv('WORKSPACE_PATH', '')
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
const bundled = await getAgents()
|
||||
expect(bundled.length).toBe(bundledAgents.length)
|
||||
expect(bundled[0].id).toBe('example')
|
||||
|
||||
// Phase 2: Workspace created with agents → auto-discovery
|
||||
vi.stubEnv('WORKSPACE_PATH', WS)
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return false
|
||||
if (p === `${WS}/SOUL.md`) return true
|
||||
if (p === `${WS}/IDENTITY.md`) return false
|
||||
if (p === `${WS}/agents`) return true
|
||||
if (p === `${WS}/agents/helper/SOUL.md`) return true
|
||||
if (p === `${WS}/agents/helper/sub-agents`) return false
|
||||
if (p === `${WS}/agents/helper/members`) return false
|
||||
return false
|
||||
})
|
||||
mockReaddirSync.mockReturnValue([
|
||||
{ name: 'helper', isDirectory: () => true },
|
||||
])
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/SOUL.md`) return '# SOUL.md — MyBot\nI help.'
|
||||
if (p === `${WS}/agents/helper/SOUL.md`) return '# Helper — Task Assistant\nI assist.'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const discovered = await getAgents()
|
||||
expect(discovered.length).toBe(2)
|
||||
expect(discovered.map(a => a.id)).toContain('mybot')
|
||||
expect(discovered.map(a => a.id)).toContain('helper')
|
||||
})
|
||||
|
||||
it('agents upgrade from auto-discovery → user override when agents.json appears', async () => {
|
||||
vi.stubEnv('WORKSPACE_PATH', WS)
|
||||
|
||||
// Phase 1: Auto-discovery
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return false
|
||||
if (p === `${WS}/SOUL.md`) return false
|
||||
if (p === `${WS}/agents`) return true
|
||||
if (p === `${WS}/agents/bot/SOUL.md`) return true
|
||||
if (p === `${WS}/agents/bot/sub-agents`) return false
|
||||
if (p === `${WS}/agents/bot/members`) return false
|
||||
return false
|
||||
})
|
||||
mockReaddirSync.mockReturnValue([
|
||||
{ name: 'bot', isDirectory: () => true },
|
||||
])
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/agents/bot/SOUL.md`) return '# Bot\nContent.'
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const auto = await getAgents()
|
||||
expect(auto.length).toBe(1)
|
||||
expect(auto[0].id).toBe('bot')
|
||||
|
||||
// Phase 2: User drops agents.json → override
|
||||
const customAgents = [
|
||||
{ id: 'custom-root', name: 'Root', title: 'Boss', reportsTo: null, directReports: [], soulPath: null, voiceId: null, color: '#ff0000', emoji: 'R', tools: ['exec'], memoryPath: null, description: 'Boss.' },
|
||||
]
|
||||
mockExistsSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return true
|
||||
return false
|
||||
})
|
||||
mockReadFileSync.mockImplementation((p: string) => {
|
||||
if (p === `${WS}/clawport/agents.json`) return JSON.stringify(customAgents)
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const overridden = await getAgents()
|
||||
expect(overridden.length).toBe(1)
|
||||
expect(overridden[0].id).toBe('custom-root')
|
||||
expect(overridden[0].name).toBe('Root')
|
||||
})
|
||||
|
||||
it('memory grows as user accumulates daily logs', () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const d1 = new Date(Date.now() - 86400000).toISOString().slice(0, 10)
|
||||
const d2 = new Date(Date.now() - 86400000 * 2).toISOString().slice(0, 10)
|
||||
|
||||
// Day 1: only MEMORY.md
|
||||
const day1Files = [
|
||||
{ label: 'LTM', path: `${WS}/MEMORY.md`, relativePath: 'MEMORY.md', content: '# Fresh memory', lastModified: d2, sizeBytes: 100, category: 'evergreen' as const },
|
||||
]
|
||||
const stats1 = computeMemoryStats(day1Files)
|
||||
expect(stats1.totalFiles).toBe(1)
|
||||
expect(stats1.dailyLogCount).toBe(0)
|
||||
expect(stats1.evergreenCount).toBe(1)
|
||||
|
||||
// Day 3: MEMORY.md + 2 daily logs
|
||||
const day3Files = [
|
||||
...day1Files,
|
||||
{ label: 'Daily', path: `${WS}/memory/${d1}.md`, relativePath: `memory/${d1}.md`, content: '', lastModified: d1, sizeBytes: 200, category: 'daily' as const },
|
||||
{ label: 'Daily', path: `${WS}/memory/${today}.md`, relativePath: `memory/${today}.md`, content: '', lastModified: today, sizeBytes: 300, category: 'daily' as const },
|
||||
]
|
||||
const stats3 = computeMemoryStats(day3Files)
|
||||
expect(stats3.totalFiles).toBe(3)
|
||||
expect(stats3.dailyLogCount).toBe(2)
|
||||
expect(stats3.totalSizeBytes).toBe(600)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user