fix: Resolve soft delete and add encrypted config support
- Fix soft delete functionality by filtering out deleted items in search results - Add support for encrypted configuration storage and retrieval - Copy test suite and vitest config to ensure all functionality works - Maintain all core brainy functionality during repository cleanup
This commit is contained in:
parent
7432fab3bc
commit
3d80df1726
62 changed files with 13792 additions and 6 deletions
|
|
@ -2929,10 +2929,16 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out placeholder nouns from search results
|
// Filter out placeholder nouns and deleted items from search results
|
||||||
searchResults = searchResults.filter((result) => {
|
searchResults = searchResults.filter((result) => {
|
||||||
if (result.metadata && typeof result.metadata === 'object') {
|
if (result.metadata && typeof result.metadata === 'object') {
|
||||||
const metadata = result.metadata as Record<string, any>
|
const metadata = result.metadata as Record<string, any>
|
||||||
|
|
||||||
|
// Exclude deleted items from search results (soft delete)
|
||||||
|
if (metadata.deleted === true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Exclude placeholder nouns from search results
|
// Exclude placeholder nouns from search results
|
||||||
if (metadata.isPlaceholder) {
|
if (metadata.isPlaceholder) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -6625,20 +6631,31 @@ export class BrainyData<T = any> implements BrainyDataInterface<T> {
|
||||||
/**
|
/**
|
||||||
* Get a configuration value with automatic decryption
|
* Get a configuration value with automatic decryption
|
||||||
* @param key Configuration key
|
* @param key Configuration key
|
||||||
|
* @param options Options including decryption (auto-detected by default)
|
||||||
* @returns Configuration value or undefined
|
* @returns Configuration value or undefined
|
||||||
*/
|
*/
|
||||||
async getConfig(key: string): Promise<any> {
|
async getConfig(key: string, options?: { decrypt?: boolean }): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const results = await this.search('', 1, {
|
const results = await this.search(`config ${key}`, 10, {
|
||||||
nounTypes: [NounType.State],
|
nounTypes: [NounType.State],
|
||||||
metadata: { configKey: key }
|
metadata: { configKey: key }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (results.length === 0) return undefined
|
if (results.length === 0) return undefined
|
||||||
|
|
||||||
const configNoun = results[0]
|
const result = results[0]
|
||||||
const value = (configNoun as any).data?.configValue || (configNoun as any).metadata?.configValue
|
|
||||||
const encrypted = (configNoun as any).data?.encrypted || (configNoun as any).metadata?.encrypted
|
// Check if the config data is in the vector data (from the add call)
|
||||||
|
// The search result has: { id, score, vector, metadata }
|
||||||
|
// The actual config object was the data that got vectorized
|
||||||
|
|
||||||
|
// Try to get the stored noun data first
|
||||||
|
const storedNoun = await this.get(result.id)
|
||||||
|
if (!storedNoun) return undefined
|
||||||
|
|
||||||
|
// The stored noun should contain our original configNoun object
|
||||||
|
const value = (storedNoun as any).configValue
|
||||||
|
const encrypted = (storedNoun as any).encrypted || result.metadata?.encrypted
|
||||||
|
|
||||||
if (encrypted && typeof value === 'string') {
|
if (encrypted && typeof value === 'string') {
|
||||||
const decrypted = await this.decryptData(value)
|
const decrypted = await this.decryptData(value)
|
||||||
|
|
|
||||||
219
tests/auto-configuration.test.ts
Normal file
219
tests/auto-configuration.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
/**
|
||||||
|
* Tests for automatic cache configuration system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { cleanupWorkerPools } from '../src/utils/index.js'
|
||||||
|
|
||||||
|
describe('Auto-Configuration System', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.clear()
|
||||||
|
}
|
||||||
|
await cleanupWorkerPools()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Automatic Cache Configuration', () => {
|
||||||
|
it('should auto-configure cache for memory storage', async () => {
|
||||||
|
// Create instance without explicit cache configuration
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
logging: { verbose: false } // Disable logging for test
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const cacheStats = brainy.getCacheStats()
|
||||||
|
|
||||||
|
// Cache should be enabled by default
|
||||||
|
expect(cacheStats.search.enabled).toBe(true)
|
||||||
|
expect(cacheStats.search.maxSize).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should auto-configure for distributed S3 storage', async () => {
|
||||||
|
// Create instance with S3 storage configuration
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true // Use memory for testing, but auto-configurator should detect S3 intent
|
||||||
|
},
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const cacheStats = brainy.getCacheStats()
|
||||||
|
const realtimeConfig = brainy.getRealtimeUpdateConfig()
|
||||||
|
|
||||||
|
// With memory storage, real-time updates should be disabled by default
|
||||||
|
// But cache should still be properly configured
|
||||||
|
expect(cacheStats.search.enabled).toBe(true)
|
||||||
|
expect(cacheStats.search.maxSize).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect explicit configuration over auto-configuration', async () => {
|
||||||
|
const explicitConfig = {
|
||||||
|
enabled: true,
|
||||||
|
maxSize: 999,
|
||||||
|
maxAge: 123456
|
||||||
|
}
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
searchCache: explicitConfig,
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const cacheStats = brainy.getCacheStats()
|
||||||
|
|
||||||
|
// Should use explicit configuration
|
||||||
|
expect(cacheStats.search.enabled).toBe(true)
|
||||||
|
expect(cacheStats.search.maxSize).toBe(999)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should adapt cache configuration based on usage patterns', async () => {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await brainy.add({
|
||||||
|
id: `test-${i}`,
|
||||||
|
text: `test data ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial cache configuration
|
||||||
|
const initialStats = brainy.getCacheStats()
|
||||||
|
const initialMaxSize = initialStats.search.maxSize
|
||||||
|
|
||||||
|
// Perform many searches to create usage patterns
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await brainy.search(`test data ${i % 5}`, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual trigger of adaptation (normally happens during real-time updates)
|
||||||
|
// Since we're testing with memory storage, we'll manually check the configurator is working
|
||||||
|
const currentStats = brainy.getCacheStats()
|
||||||
|
|
||||||
|
// Cache should still be operational and have reasonable settings
|
||||||
|
expect(currentStats.search.enabled).toBe(true)
|
||||||
|
expect(currentStats.search.maxSize).toBeGreaterThan(0)
|
||||||
|
expect(currentStats.search.hits).toBeGreaterThan(0) // Should have cache hits
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Environment-Specific Auto-Configuration', () => {
|
||||||
|
it('should configure differently for read-heavy vs write-heavy workloads', async () => {
|
||||||
|
// Test read-heavy configuration
|
||||||
|
const readHeavyBrainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await readHeavyBrainy.init()
|
||||||
|
|
||||||
|
// Add some data
|
||||||
|
await readHeavyBrainy.add({ id: 'test-1', text: 'test data' })
|
||||||
|
|
||||||
|
// Simulate read-heavy usage
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await readHeavyBrainy.search('test data', 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readHeavyStats = readHeavyBrainy.getCacheStats()
|
||||||
|
|
||||||
|
// Should have good cache performance
|
||||||
|
expect(readHeavyStats.search.hitRate).toBeGreaterThan(0.5)
|
||||||
|
expect(readHeavyStats.search.enabled).toBe(true)
|
||||||
|
|
||||||
|
await readHeavyBrainy.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle zero-configuration scenarios gracefully', async () => {
|
||||||
|
// Create instance with absolutely minimal configuration
|
||||||
|
brainy = new BrainyData({
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should still work with auto-detected configuration
|
||||||
|
await brainy.add({ text: 'auto-config test unique phrase' })
|
||||||
|
const results = await brainy.search('unique phrase', 5)
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Cache should be configured by auto-configurator
|
||||||
|
const stats = brainy.getCacheStats()
|
||||||
|
expect(stats.search.enabled).toBe(true)
|
||||||
|
expect(stats.search.maxSize).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Configuration Explanations', () => {
|
||||||
|
it('should provide configuration explanations when verbose logging is enabled', async () => {
|
||||||
|
// Capture console output
|
||||||
|
const consoleLogs: string[] = []
|
||||||
|
const originalLog = console.log
|
||||||
|
console.log = (...args: any[]) => {
|
||||||
|
consoleLogs.push(args.join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
},
|
||||||
|
logging: { verbose: true }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should have logged configuration explanation
|
||||||
|
const configLogs = consoleLogs.filter(log =>
|
||||||
|
log.includes('Auto-Configuration') ||
|
||||||
|
log.includes('Distributed storage detected') ||
|
||||||
|
log.includes('Cache:') ||
|
||||||
|
log.includes('Updates:')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(configLogs.length).toBeGreaterThan(0)
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Optimization', () => {
|
||||||
|
it('should optimize cache settings for different scenarios', async () => {
|
||||||
|
// Test with high-performance configuration
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await brainy.add({
|
||||||
|
id: `perf-test-${i}`,
|
||||||
|
text: `performance test data ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform searches to warm up cache
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await brainy.search(`performance test data ${i % 5}`, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = brainy.getCacheStats()
|
||||||
|
|
||||||
|
// Should have good performance characteristics
|
||||||
|
expect(stats.search.hits).toBeGreaterThan(0)
|
||||||
|
expect(stats.search.hitRate).toBeGreaterThan(0.3) // At least 30% hit rate
|
||||||
|
expect(stats.searchMemoryUsage).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
47
tests/base-hnsw-test.ts
Normal file
47
tests/base-hnsw-test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Test using base HNSW directly
|
||||||
|
import { HNSWIndex } from '../dist/hnsw/hnswIndex.js'
|
||||||
|
import { euclideanDistance } from '../dist/utils/distance.js'
|
||||||
|
|
||||||
|
async function testBaseHNSW() {
|
||||||
|
console.log('🧪 Testing base HNSW directly...')
|
||||||
|
|
||||||
|
const index = new HNSWIndex(
|
||||||
|
{ M: 4, efConstruction: 20, efSearch: 50 },
|
||||||
|
euclideanDistance
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create test vectors
|
||||||
|
const aliceVector = Array.from({length: 384}, () => Math.random())
|
||||||
|
const bobVector = Array.from({length: 384}, () => Math.random())
|
||||||
|
const queryVector = Array.from({length: 384}, () => Math.random())
|
||||||
|
|
||||||
|
// Add items to index
|
||||||
|
const aliceId = 'alice-123'
|
||||||
|
const bobId = 'bob-456'
|
||||||
|
|
||||||
|
await index.addItem({ id: aliceId, vector: aliceVector })
|
||||||
|
await index.addItem({ id: bobId, vector: bobVector })
|
||||||
|
|
||||||
|
console.log('Added items to index')
|
||||||
|
|
||||||
|
// Test without filter
|
||||||
|
const allResults = await index.search(queryVector, 10)
|
||||||
|
console.log('All results:', allResults.length)
|
||||||
|
|
||||||
|
// Test with filter - only allow Alice
|
||||||
|
const aliceOnlyFilter = async (id: string) => {
|
||||||
|
console.log('🔍 Filter called for:', id, id === aliceId ? '✅ ALLOW' : '❌ BLOCK')
|
||||||
|
return id === aliceId
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Testing with filter...')
|
||||||
|
const filteredResults = await index.search(queryVector, 10, aliceOnlyFilter)
|
||||||
|
console.log('Filtered results:', filteredResults.length)
|
||||||
|
|
||||||
|
const shouldWork = filteredResults.length === 1 && filteredResults[0][0] === aliceId
|
||||||
|
console.log(shouldWork ? '✅ FILTERING WORKS!' : '❌ FILTERING FAILED!')
|
||||||
|
|
||||||
|
return shouldWork
|
||||||
|
}
|
||||||
|
|
||||||
|
testBaseHNSW().catch(console.error)
|
||||||
100
tests/brainy-chat.test.ts
Normal file
100
tests/brainy-chat.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { BrainyChat } from '../src/chat/BrainyChat.js'
|
||||||
|
|
||||||
|
describe('BrainyChat', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
let chat: BrainyChat
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData({ storage: { type: 'memory' } })
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
await brainy.add('Customer Support Documentation', {
|
||||||
|
type: 'doc',
|
||||||
|
category: 'support',
|
||||||
|
content: 'How to reset password: Go to Settings > Security > Reset Password'
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.add('Product Catalog', {
|
||||||
|
type: 'doc',
|
||||||
|
category: 'products',
|
||||||
|
content: 'We offer electronics, books, clothing, and home goods'
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.add('Sales Report Q4 2024', {
|
||||||
|
type: 'report',
|
||||||
|
category: 'sales',
|
||||||
|
revenue: 2500000,
|
||||||
|
growth: 0.15
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Template-based responses (no LLM)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chat = new BrainyChat(brainy)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should answer count questions', async () => {
|
||||||
|
const answer = await chat.ask('How many documents do we have?')
|
||||||
|
expect(answer).toContain('found')
|
||||||
|
expect(answer).toContain('relevant items')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should answer list questions', async () => {
|
||||||
|
const answer = await chat.ask('What are our product categories?')
|
||||||
|
expect(answer).toContain('top results')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle questions with low relevance', async () => {
|
||||||
|
const answer = await chat.ask('Tell me about quantum computing')
|
||||||
|
// Since semantic search might find some weak matches, check for either no results or low relevance
|
||||||
|
expect(answer).toBeDefined()
|
||||||
|
expect(answer.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include sources when requested', async () => {
|
||||||
|
chat = new BrainyChat(brainy, { sources: true })
|
||||||
|
const answer = await chat.ask('How do I reset my password?')
|
||||||
|
expect(answer).toContain('[Sources:')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('With LLM (mocked)', () => {
|
||||||
|
it('should detect Claude model', () => {
|
||||||
|
const chatWithClaude = new BrainyChat(brainy, {
|
||||||
|
llm: 'claude-3-5-sonnet'
|
||||||
|
})
|
||||||
|
expect(chatWithClaude).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect OpenAI model', () => {
|
||||||
|
const chatWithGPT = new BrainyChat(brainy, {
|
||||||
|
llm: 'gpt-4o-mini'
|
||||||
|
})
|
||||||
|
expect(chatWithGPT).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect Hugging Face model', () => {
|
||||||
|
const chatWithHF = new BrainyChat(brainy, {
|
||||||
|
llm: 'Xenova/LaMini-Flan-T5-77M'
|
||||||
|
})
|
||||||
|
expect(chatWithHF).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('History tracking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chat = new BrainyChat(brainy)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain conversation history', async () => {
|
||||||
|
await chat.ask('What products do we sell?')
|
||||||
|
const answer = await chat.ask('Tell me more about the first one')
|
||||||
|
// The template should still provide an answer
|
||||||
|
expect(answer).toBeDefined()
|
||||||
|
expect(answer.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
258
tests/cli.test.ts
Normal file
258
tests/cli.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* CLI Tests for Brainy 1.0
|
||||||
|
* Tests the 9 clean CLI commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { existsSync, rmSync, mkdirSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const CLI_PATH = path.resolve('./bin/brainy.js')
|
||||||
|
const TEST_DB_PATH = path.resolve('./test-cli-db')
|
||||||
|
|
||||||
|
describe.skip('Brainy 1.0 CLI Commands', () => {
|
||||||
|
// TODO: Fix undefined cortex and importer references before enabling
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clean up any existing test database
|
||||||
|
if (existsSync(TEST_DB_PATH)) {
|
||||||
|
rmSync(TEST_DB_PATH, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
mkdirSync(TEST_DB_PATH, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up test database after each test
|
||||||
|
if (existsSync(TEST_DB_PATH)) {
|
||||||
|
rmSync(TEST_DB_PATH, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function runCLI(args: string): string {
|
||||||
|
try {
|
||||||
|
return execSync(`node ${CLI_PATH} ${args}`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: TEST_DB_PATH,
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`CLI command failed: ${error.message}\nOutput: ${error.stdout || error.stderr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Command 1: brainy init', () => {
|
||||||
|
it('should initialize a new brainy database', () => {
|
||||||
|
const output = runCLI('init')
|
||||||
|
expect(output).toContain('initialized')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with encryption option', () => {
|
||||||
|
const output = runCLI('init --encryption')
|
||||||
|
expect(output).toContain('encryption')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with storage option', () => {
|
||||||
|
const output = runCLI('init --storage memory')
|
||||||
|
expect(output).toContain('memory')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 2: brainy add', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with smart processing by default', () => {
|
||||||
|
const output = runCLI('add "John Doe is a software engineer"')
|
||||||
|
expect(output).toContain('added')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with literal processing', () => {
|
||||||
|
const output = runCLI('add "Raw data" --literal')
|
||||||
|
expect(output).toContain('added')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with metadata', () => {
|
||||||
|
const output = runCLI('add "Jane Smith" --metadata \'{"role":"manager"}\'')
|
||||||
|
expect(output).toContain('added')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add encrypted data', () => {
|
||||||
|
const output = runCLI('add "Sensitive information" --encrypt')
|
||||||
|
expect(output).toContain('added')
|
||||||
|
expect(output).toContain('encrypted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 3: brainy search', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
runCLI('add "Alice is a data scientist"')
|
||||||
|
runCLI('add "Bob is a software engineer"')
|
||||||
|
runCLI('add "Charlie works in marketing"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search for similar content', () => {
|
||||||
|
const output = runCLI('search "data scientist"')
|
||||||
|
expect(output).toContain('Alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search with limit', () => {
|
||||||
|
const output = runCLI('search "engineer" --limit 1')
|
||||||
|
expect(output).toContain('Bob')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search with metadata filters', () => {
|
||||||
|
runCLI('add "David" --metadata \'{"dept":"engineering"}\'')
|
||||||
|
const output = runCLI('search "" --filter \'{"dept":"engineering"}\'')
|
||||||
|
expect(output).toContain('David')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 4: brainy update', () => {
|
||||||
|
let itemId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
const output = runCLI('add "Original content"')
|
||||||
|
const match = output.match(/ID:\s*([a-zA-Z0-9-]+)/)
|
||||||
|
itemId = match ? match[1] : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update existing data', () => {
|
||||||
|
const output = runCLI(`update ${itemId} --data "Updated content"`)
|
||||||
|
expect(output).toContain('updated')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update with new metadata', () => {
|
||||||
|
const output = runCLI(`update ${itemId} --data "Updated content" --metadata '{"version":2}'`)
|
||||||
|
expect(output).toContain('updated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 5: brainy delete', () => {
|
||||||
|
let itemId: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
const output = runCLI('add "Content to delete"')
|
||||||
|
const match = output.match(/ID:\s*([a-zA-Z0-9-]+)/)
|
||||||
|
itemId = match ? match[1] : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should soft delete by default', () => {
|
||||||
|
const output = runCLI(`delete ${itemId}`)
|
||||||
|
expect(output).toContain('deleted')
|
||||||
|
|
||||||
|
// Should not appear in search
|
||||||
|
const searchOutput = runCLI('search "Content to delete"')
|
||||||
|
expect(searchOutput).not.toContain('Content to delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hard delete when specified', () => {
|
||||||
|
const output = runCLI(`delete ${itemId} --hard`)
|
||||||
|
expect(output).toContain('deleted')
|
||||||
|
expect(output).toContain('hard')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 6: brainy import', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should import from JSON array string', () => {
|
||||||
|
const jsonData = '["Item 1", "Item 2", "Item 3"]'
|
||||||
|
const output = runCLI(`import '${jsonData}'`)
|
||||||
|
expect(output).toContain('imported')
|
||||||
|
expect(output).toContain('3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 7: brainy status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
runCLI('add "Test data 1"')
|
||||||
|
runCLI('add "Test data 2"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show database status', () => {
|
||||||
|
const output = runCLI('status')
|
||||||
|
expect(output).toContain('Status')
|
||||||
|
expect(output).toMatch(/\d+/) // Should contain numbers (counts)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show per-service statistics', () => {
|
||||||
|
const output = runCLI('status --detailed')
|
||||||
|
expect(output).toContain('Statistics')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 8: brainy config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set configuration value', () => {
|
||||||
|
const output = runCLI('config set api-key "test-key"')
|
||||||
|
expect(output).toContain('set')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get configuration value', () => {
|
||||||
|
runCLI('config set test-setting "test-value"')
|
||||||
|
const output = runCLI('config get test-setting')
|
||||||
|
expect(output).toContain('test-value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should list all configuration', () => {
|
||||||
|
runCLI('config set key1 "value1"')
|
||||||
|
runCLI('config set key2 "value2"')
|
||||||
|
const output = runCLI('config list')
|
||||||
|
expect(output).toContain('key1')
|
||||||
|
expect(output).toContain('key2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Command 9: brainy chat', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
runCLI('init')
|
||||||
|
runCLI('add "Alice is a data scientist working on machine learning"')
|
||||||
|
runCLI('add "Bob is a software engineer building web applications"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide help when no LLM is configured', () => {
|
||||||
|
const output = runCLI('chat "Who is Alice?"')
|
||||||
|
// Since no LLM is configured in tests, it should provide helpful guidance
|
||||||
|
expect(output).toContain('chat') // Should contain some chat-related response
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CLI Help and Version', () => {
|
||||||
|
it('should show help', () => {
|
||||||
|
const output = runCLI('--help')
|
||||||
|
expect(output).toContain('Usage')
|
||||||
|
expect(output).toContain('Commands')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show version', () => {
|
||||||
|
const output = runCLI('--version')
|
||||||
|
expect(output).toMatch(/\d+\.\d+\.\d+/) // Should show version number
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle invalid commands gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
runCLI('invalid-command')
|
||||||
|
}).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing arguments', () => {
|
||||||
|
runCLI('init')
|
||||||
|
expect(() => {
|
||||||
|
runCLI('add') // Missing data argument
|
||||||
|
}).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
380
tests/core.test.ts
Normal file
380
tests/core.test.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
/**
|
||||||
|
* Core Functionality Tests
|
||||||
|
* Tests core Brainy features as a consumer would use them
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a 512-dimensional vector for testing
|
||||||
|
* @param primaryIndex The index to set to 1.0, all other indices will be 0.0
|
||||||
|
* @returns A 512-dimensional vector with a single 1.0 value at the specified index
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 512] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Brainy Core Functionality', () => {
|
||||||
|
let brainy: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Load brainy library as a consumer would
|
||||||
|
brainy = await import('../src/index.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Library Exports', () => {
|
||||||
|
it('should export BrainyData class', () => {
|
||||||
|
expect(brainy.BrainyData).toBeDefined()
|
||||||
|
expect(typeof brainy.BrainyData).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export environment detection functions', () => {
|
||||||
|
expect(typeof brainy.isBrowser).toBe('function')
|
||||||
|
expect(typeof brainy.isNode).toBe('function')
|
||||||
|
expect(typeof brainy.isWebWorker).toBe('function')
|
||||||
|
expect(typeof brainy.areWebWorkersAvailable).toBe('function')
|
||||||
|
expect(typeof brainy.isThreadingAvailable).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export embedding function creator', () => {
|
||||||
|
expect(typeof brainy.createEmbeddingFunction).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export environment detection functions', () => {
|
||||||
|
expect(typeof brainy.isBrowser).toBe('function')
|
||||||
|
expect(typeof brainy.isNode).toBe('function')
|
||||||
|
expect(typeof brainy.isWebWorker).toBe('function')
|
||||||
|
expect(typeof brainy.areWebWorkersAvailable).toBe('function')
|
||||||
|
expect(typeof brainy.isThreadingAvailable).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BrainyData Configuration', () => {
|
||||||
|
it('should create instance with minimal configuration', () => {
|
||||||
|
const data = new brainy.BrainyData({})
|
||||||
|
|
||||||
|
expect(data).toBeDefined()
|
||||||
|
expect(data.dimensions).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create instance with full configuration', () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'cosine',
|
||||||
|
maxConnections: 32,
|
||||||
|
efConstruction: 200,
|
||||||
|
storage: 'memory'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(data).toBeDefined()
|
||||||
|
expect(data.dimensions).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw with valid configuration parameters', () => {
|
||||||
|
// Dimensions are now fixed at 512 and not configurable
|
||||||
|
expect(() => {
|
||||||
|
new brainy.BrainyData({
|
||||||
|
metric: 'cosine'
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use default values for optional parameters', () => {
|
||||||
|
const data = new brainy.BrainyData({})
|
||||||
|
|
||||||
|
expect(data.dimensions).toBe(384)
|
||||||
|
// Should have reasonable defaults for other parameters
|
||||||
|
expect(data.maxConnections).toBeGreaterThan(0)
|
||||||
|
expect(data.efConstruction).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Vector Operations', () => {
|
||||||
|
it('should handle vector addition and search', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add vectors using helper function
|
||||||
|
await data.add(createTestVector(0), { id: 'v1', label: 'x-axis' })
|
||||||
|
await data.add(createTestVector(1), { id: 'v2', label: 'y-axis' })
|
||||||
|
await data.add(createTestVector(2), { id: 'v3', label: 'z-axis' })
|
||||||
|
|
||||||
|
// Search for similar vector
|
||||||
|
const results = await data.search(createTestVector(0), 1)
|
||||||
|
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].metadata.id).toBe('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle batch vector operations', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add multiple vectors
|
||||||
|
const vectors = [
|
||||||
|
{ vector: createTestVector(10), metadata: { id: 'batch1' } },
|
||||||
|
{ vector: createTestVector(20), metadata: { id: 'batch2' } },
|
||||||
|
{ vector: createTestVector(30), metadata: { id: 'batch3' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { vector, metadata } of vectors) {
|
||||||
|
await data.add(vector, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search should return results
|
||||||
|
const results = await data.search(createTestVector(15), 3)
|
||||||
|
expect(results.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different distance metrics', async () => {
|
||||||
|
const euclideanData = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
const cosineData = new brainy.BrainyData({
|
||||||
|
metric: 'cosine'
|
||||||
|
})
|
||||||
|
|
||||||
|
await euclideanData.init()
|
||||||
|
await cosineData.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure test isolation
|
||||||
|
await euclideanData.clear()
|
||||||
|
await cosineData.clear()
|
||||||
|
|
||||||
|
const vector = createTestVector(5)
|
||||||
|
const metadata = { id: 'test' }
|
||||||
|
|
||||||
|
await euclideanData.add(vector, metadata)
|
||||||
|
await cosineData.add(vector, metadata)
|
||||||
|
|
||||||
|
const euclideanResults = await euclideanData.search(vector, 1)
|
||||||
|
const cosineResults = await cosineData.search(vector, 1)
|
||||||
|
|
||||||
|
expect(euclideanResults.length).toBe(1)
|
||||||
|
expect(cosineResults.length).toBe(1)
|
||||||
|
|
||||||
|
// Both should find the exact match, but distances might differ
|
||||||
|
expect(euclideanResults[0].metadata.id).toBe('test')
|
||||||
|
expect(cosineResults[0].metadata.id).toBe('test')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Text Processing', () => {
|
||||||
|
it(
|
||||||
|
'should handle text items with embedding function',
|
||||||
|
async () => {
|
||||||
|
const embeddingFunction = brainy.createEmbeddingFunction()
|
||||||
|
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
embeddingFunction,
|
||||||
|
dimensions: 384, // Universal Sentence Encoder produces 512-dimensional vectors
|
||||||
|
metric: 'cosine',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
|
||||||
|
// Add text items
|
||||||
|
await data.addItem('Hello world', { id: 'greeting', type: 'text' })
|
||||||
|
await data.addItem('Goodbye world', { id: 'farewell', type: 'text' })
|
||||||
|
|
||||||
|
// Search with text
|
||||||
|
const results = await data.search('Hi there', 1)
|
||||||
|
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].metadata).toHaveProperty('id')
|
||||||
|
},
|
||||||
|
globalThis.testUtils?.timeout || 30000
|
||||||
|
)
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle mixed vector and text operations',
|
||||||
|
async () => {
|
||||||
|
const embeddingFunction = brainy.createEmbeddingFunction()
|
||||||
|
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
embeddingFunction,
|
||||||
|
dimensions: 384, // Universal Sentence Encoder produces 512-dimensional vectors
|
||||||
|
metric: 'cosine'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
|
||||||
|
// Add text item
|
||||||
|
await data.addItem('Machine learning', { id: 'text1', type: 'text' })
|
||||||
|
|
||||||
|
// Add vector item (using embedding of similar text)
|
||||||
|
const embedding = await embeddingFunction('Artificial intelligence')
|
||||||
|
await data.add(embedding, { id: 'vector1', type: 'vector' })
|
||||||
|
|
||||||
|
// Search should find both
|
||||||
|
const results = await data.search('AI and ML', 2)
|
||||||
|
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
},
|
||||||
|
globalThis.testUtils?.timeout || 30000
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle invalid vector dimensions', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
|
||||||
|
// Try to add vector with wrong dimensions
|
||||||
|
await expect(data.add([1, 2], { id: 'wrong' })).rejects.toThrow()
|
||||||
|
await expect(
|
||||||
|
data.add(new Array(100).fill(0), { id: 'wrong' })
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle search before initialization', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to search without initialization
|
||||||
|
await expect(data.search(createTestVector(0), 1)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty search results gracefully', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Search in empty database
|
||||||
|
const results = await data.search(createTestVector(0), 1)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance and Scalability', () => {
|
||||||
|
it('should handle moderate number of vectors efficiently', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Add 100 test vectors
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await data.add(createTestVector(i), { id: `item_${i}`, index: i })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTime = Date.now() - startTime
|
||||||
|
|
||||||
|
// Search should be fast
|
||||||
|
const searchStart = Date.now()
|
||||||
|
const results = await data.search(createTestVector(50), 10)
|
||||||
|
const searchTime = Date.now() - searchStart
|
||||||
|
|
||||||
|
expect(results.length).toBeLessThanOrEqual(10)
|
||||||
|
expect(addTime).toBeLessThan(10000) // Should complete within 10 seconds
|
||||||
|
expect(searchTime).toBeLessThan(1000) // Search should be under 1 second
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain search quality with more data', async () => {
|
||||||
|
// Create database with proper configuration for testing
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
embeddingFunction: brainy.createEmbeddingFunction(),
|
||||||
|
metric: 'cosine'
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add known data
|
||||||
|
await db.add('known data', { id: 'known' })
|
||||||
|
|
||||||
|
// Add noise data
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await db.add(`noise_${i}`, { id: `noise_${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform search using the correct method
|
||||||
|
const results = await db.search('known data', 10)
|
||||||
|
|
||||||
|
// Debugging output
|
||||||
|
console.log(
|
||||||
|
'Search results:',
|
||||||
|
results.map((r) => r.metadata?.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
// The 'known' item should be found in the results, but not necessarily first
|
||||||
|
// due to potential variations in embedding similarity calculations
|
||||||
|
const knownItemFound = results.some((r) => r.metadata?.id === 'known')
|
||||||
|
expect(knownItemFound).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Database Statistics', () => {
|
||||||
|
it('should provide statistics structure even if counts are not tracked', async () => {
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: { type: 'memory' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add some vectors (nouns)
|
||||||
|
await data.add(createTestVector(0), { id: 'v1', label: 'x-axis' })
|
||||||
|
await data.add(createTestVector(1), { id: 'v2', label: 'y-axis' })
|
||||||
|
await data.add(createTestVector(2), { id: 'v3', label: 'z-axis' })
|
||||||
|
|
||||||
|
// Add some connections (verbs)
|
||||||
|
await data.connect('v1', 'v2', 'related_to')
|
||||||
|
await data.connect('v2', 'v3', 'related_to')
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
const stats = await data.getStatistics()
|
||||||
|
|
||||||
|
// Verify statistics structure exists
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats).toHaveProperty('nounCount')
|
||||||
|
expect(stats).toHaveProperty('verbCount')
|
||||||
|
expect(stats).toHaveProperty('metadataCount')
|
||||||
|
expect(stats).toHaveProperty('hnswIndexSize')
|
||||||
|
|
||||||
|
// Note: Automatic statistics tracking is not implemented in storage adapters
|
||||||
|
// This test now just verifies the structure exists, not the actual counts
|
||||||
|
// For accurate statistics, they need to be manually tracked and saved
|
||||||
|
|
||||||
|
// At minimum, the hnswIndexSize should reflect the actual HNSW index
|
||||||
|
expect(stats.hnswIndexSize).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
64
tests/database-operations.test.ts
Normal file
64
tests/database-operations.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { BrainyData } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Database Operations', () => {
|
||||||
|
let db: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize and return database status', async () => {
|
||||||
|
const status = await db.status()
|
||||||
|
expect(status).toBeDefined()
|
||||||
|
// The structure of status might vary, just check it exists
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return statistics', async () => {
|
||||||
|
const stats = await db.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
// The structure of stats might vary, just check it exists
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retrieve all nouns', async () => {
|
||||||
|
const nouns = await db.getAllNouns()
|
||||||
|
expect(Array.isArray(nouns)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retrieve all verbs', async () => {
|
||||||
|
const verbs = await db.getAllVerbs()
|
||||||
|
expect(Array.isArray(verbs)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should perform a search operation', async () => {
|
||||||
|
const searchResults = await db.searchText('test', 10)
|
||||||
|
expect(Array.isArray(searchResults)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add and retrieve an item', async () => {
|
||||||
|
// Add a test item
|
||||||
|
const testText = 'This is a test item for searching'
|
||||||
|
const metadata = { noun: 'Thing', category: 'test' }
|
||||||
|
const id = await db.add(testText, metadata)
|
||||||
|
|
||||||
|
// Verify the item was added
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Retrieve the item
|
||||||
|
const noun = await db.get(id)
|
||||||
|
expect(noun).toBeDefined()
|
||||||
|
expect(noun.id).toBe(id)
|
||||||
|
|
||||||
|
// Check that the metadata contains our properties
|
||||||
|
// (The system might add additional properties)
|
||||||
|
expect(noun.metadata.category).toBe('test')
|
||||||
|
|
||||||
|
// Search for the item
|
||||||
|
const searchResults = await db.searchText('test', 10)
|
||||||
|
expect(searchResults.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await db.delete(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
59
tests/dimension-standardization.test.ts
Normal file
59
tests/dimension-standardization.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { BrainyData } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Vector Dimension Standardization', () => {
|
||||||
|
it('should initialize BrainyData with 384 dimensions', async () => {
|
||||||
|
// Initialize BrainyData
|
||||||
|
const db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Check the dimensions property
|
||||||
|
expect(db.dimensions).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject vectors with incorrect dimensions', async () => {
|
||||||
|
const db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Test with a simple vector (this should throw an error because it's not 384 dimensions)
|
||||||
|
const smallVector = [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
|
// Expect the add operation to throw an error
|
||||||
|
await expect(db.add(smallVector, { test: 'small-vector' }))
|
||||||
|
.rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should successfully embed text to 384 dimensions', async () => {
|
||||||
|
const db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Test with text that will be embedded to 384 dimensions
|
||||||
|
const id = await db.add('This is a test text that will be embedded to 384 dimensions', { test: 'text-embedding' })
|
||||||
|
|
||||||
|
// Retrieve the vector and check its dimensions
|
||||||
|
const noun = await db.get(id)
|
||||||
|
expect(noun.vector.length).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should directly embed text to 384 dimensions', async () => {
|
||||||
|
const db = new BrainyData()
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Test direct embedding
|
||||||
|
const vector = await db.embed('Another test text')
|
||||||
|
expect(vector.length).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use the default dimensions regardless of configuration', async () => {
|
||||||
|
// Create a BrainyData instance with a specific dimension
|
||||||
|
const customDimension = 300
|
||||||
|
const db = new BrainyData({
|
||||||
|
dimensions: customDimension
|
||||||
|
})
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// The API currently uses the default dimensions (384) regardless of configuration
|
||||||
|
// This is the current behavior, though it might not be the intended behavior
|
||||||
|
expect(db.dimensions).toBe(384)
|
||||||
|
})
|
||||||
|
})
|
||||||
245
tests/distributed-caching.test.ts
Normal file
245
tests/distributed-caching.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
/**
|
||||||
|
* Tests for distributed caching behavior with shared storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { cleanupWorkerPools } from '../src/utils/index.js'
|
||||||
|
|
||||||
|
describe('Distributed Caching', () => {
|
||||||
|
let serviceA: BrainyData
|
||||||
|
let serviceB: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Mock the checkForUpdates to simulate real-time updates without waiting
|
||||||
|
const mockCheckForUpdates = vi.fn()
|
||||||
|
|
||||||
|
// Create two services sharing the same storage configuration
|
||||||
|
const sharedConfig = {
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true // Use memory storage for testing
|
||||||
|
},
|
||||||
|
searchCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxSize: 50,
|
||||||
|
maxAge: 60000 // 1 minute
|
||||||
|
},
|
||||||
|
realtimeUpdates: {
|
||||||
|
enabled: true,
|
||||||
|
interval: 1000, // 1 second for testing
|
||||||
|
updateIndex: true,
|
||||||
|
updateStatistics: true
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
verbose: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceA = new BrainyData(sharedConfig)
|
||||||
|
serviceB = new BrainyData(sharedConfig)
|
||||||
|
|
||||||
|
await serviceA.init()
|
||||||
|
await serviceB.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await serviceA.clear()
|
||||||
|
await serviceB.clear()
|
||||||
|
await cleanupWorkerPools()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cache Invalidation on External Changes', () => {
|
||||||
|
it('should invalidate cache when external data changes are detected', async () => {
|
||||||
|
// Since we're using memory storage and separate instances for testing,
|
||||||
|
// we'll simulate distributed behavior within a single service
|
||||||
|
|
||||||
|
// Add initial data
|
||||||
|
await serviceA.add({
|
||||||
|
id: 'item-1',
|
||||||
|
text: 'initial data from service A'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search and cache the result
|
||||||
|
const results1 = await serviceA.search('initial data', 5)
|
||||||
|
expect(results1.length).toBe(1)
|
||||||
|
|
||||||
|
// Verify cache is populated
|
||||||
|
let stats = serviceA.getCacheStats()
|
||||||
|
expect(stats.search.size).toBe(1)
|
||||||
|
|
||||||
|
// Add more data (simulates external changes)
|
||||||
|
await serviceA.add({
|
||||||
|
id: 'item-2',
|
||||||
|
text: 'new data from service A'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache should have been invalidated due to the add operation
|
||||||
|
stats = serviceA.getCacheStats()
|
||||||
|
expect(stats.search.size).toBe(0) // Cache cleared
|
||||||
|
|
||||||
|
// Search again - should get fresh results including new data
|
||||||
|
const results2 = await serviceA.search('data from service', 10)
|
||||||
|
expect(results2.length).toBe(2) // Should now see both items
|
||||||
|
|
||||||
|
stats = serviceA.getCacheStats()
|
||||||
|
// Cache should be rebuilt with fresh data
|
||||||
|
expect(stats.search.size).toBe(1) // New cache entry
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle cache expiration gracefully', async () => {
|
||||||
|
// Create a service with very short cache TTL
|
||||||
|
const shortCacheService = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
searchCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 100 // 100ms - very short for testing
|
||||||
|
},
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await shortCacheService.init()
|
||||||
|
|
||||||
|
// Add data and search
|
||||||
|
await shortCacheService.add({
|
||||||
|
id: 'item-1',
|
||||||
|
text: 'short cache test'
|
||||||
|
})
|
||||||
|
|
||||||
|
const results1 = await shortCacheService.search('short cache', 5)
|
||||||
|
expect(results1.length).toBe(1)
|
||||||
|
|
||||||
|
// Wait for cache to expire
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150))
|
||||||
|
|
||||||
|
// Clean up expired entries
|
||||||
|
const expiredCount = shortCacheService['searchCache'].cleanupExpiredEntries()
|
||||||
|
expect(expiredCount).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Search again - should work fine with fresh data
|
||||||
|
const results2 = await shortCacheService.search('short cache', 5)
|
||||||
|
expect(results2.length).toBe(1)
|
||||||
|
|
||||||
|
await shortCacheService.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide cache statistics for monitoring', async () => {
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await serviceA.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test data ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache to start fresh
|
||||||
|
serviceA.clearCache()
|
||||||
|
|
||||||
|
// Perform searches to populate cache
|
||||||
|
await serviceA.search('test data', 5) // Miss
|
||||||
|
await serviceA.search('test data', 5) // Hit
|
||||||
|
await serviceA.search('test data', 3) // Miss (different k)
|
||||||
|
await serviceA.search('test data', 3) // Hit
|
||||||
|
|
||||||
|
const stats = serviceA.getCacheStats()
|
||||||
|
|
||||||
|
expect(stats.search.hits).toBe(2)
|
||||||
|
expect(stats.search.misses).toBe(2)
|
||||||
|
expect(stats.search.hitRate).toBe(0.5)
|
||||||
|
expect(stats.search.size).toBe(2) // Two different cache entries
|
||||||
|
expect(stats.searchMemoryUsage).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Real-time Update Integration', () => {
|
||||||
|
it('should enable real-time updates for distributed scenarios', () => {
|
||||||
|
const config = serviceA.getRealtimeUpdateConfig()
|
||||||
|
expect(config.enabled).toBe(true)
|
||||||
|
expect(config.updateIndex).toBe(true)
|
||||||
|
expect(config.updateStatistics).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle skipCache option correctly', async () => {
|
||||||
|
// Add test data
|
||||||
|
await serviceA.add({
|
||||||
|
id: 'item-1',
|
||||||
|
text: 'skip cache test'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search with cache
|
||||||
|
const results1 = await serviceA.search('skip cache', 5)
|
||||||
|
expect(results1.length).toBe(1)
|
||||||
|
|
||||||
|
// Verify cache is populated
|
||||||
|
let stats = serviceA.getCacheStats()
|
||||||
|
expect(stats.search.size).toBe(1)
|
||||||
|
|
||||||
|
// Search with skipCache - should bypass cache
|
||||||
|
const results2 = await serviceA.search('skip cache', 5, { skipCache: true })
|
||||||
|
expect(results2.length).toBe(1)
|
||||||
|
|
||||||
|
// Cache size shouldn't increase
|
||||||
|
stats = serviceA.getCacheStats()
|
||||||
|
expect(stats.search.size).toBe(1) // Still just one entry
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Distributed Mode Best Practices', () => {
|
||||||
|
it('should work with recommended distributed settings', async () => {
|
||||||
|
const distributedService = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
searchCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 180000, // 3 minutes - shorter for distributed
|
||||||
|
maxSize: 100
|
||||||
|
},
|
||||||
|
realtimeUpdates: {
|
||||||
|
enabled: true,
|
||||||
|
interval: 30000, // 30 seconds
|
||||||
|
updateIndex: true,
|
||||||
|
updateStatistics: true
|
||||||
|
},
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await distributedService.init()
|
||||||
|
|
||||||
|
// Verify configuration
|
||||||
|
const config = distributedService.getRealtimeUpdateConfig()
|
||||||
|
expect(config.enabled).toBe(true)
|
||||||
|
expect(config.interval).toBe(30000)
|
||||||
|
|
||||||
|
const cacheStats = distributedService.getCacheStats()
|
||||||
|
expect(cacheStats.search.enabled).toBe(true)
|
||||||
|
|
||||||
|
await distributedService.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain performance with frequent external changes', async () => {
|
||||||
|
// Simulate a scenario with frequent external changes
|
||||||
|
const queries = ['query1', 'query2', 'query3', 'query4', 'query5']
|
||||||
|
|
||||||
|
// Add initial data
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await serviceA.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `data for query${(i % 5) + 1} item ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache to start measurement
|
||||||
|
serviceA.clearCache()
|
||||||
|
|
||||||
|
// Perform multiple searches (some will be cache hits)
|
||||||
|
for (const query of queries) {
|
||||||
|
await serviceA.search(query, 5) // First search - cache miss
|
||||||
|
await serviceA.search(query, 5) // Second search - cache hit
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = serviceA.getCacheStats()
|
||||||
|
|
||||||
|
// Should have good hit rate despite distributed scenario
|
||||||
|
expect(stats.search.hitRate).toBeGreaterThan(0.4) // At least 40%
|
||||||
|
expect(stats.search.hits).toBeGreaterThan(0)
|
||||||
|
expect(stats.search.size).toBe(queries.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
138
tests/distributed-config-migration.test.ts
Normal file
138
tests/distributed-config-migration.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* Tests for distributed configuration migration to index folder
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { DistributedConfigManager } from '../src/distributed/configManager.js'
|
||||||
|
import { MemoryStorage } from '../src/storage/adapters/memoryStorage.js'
|
||||||
|
import { SharedConfig } from '../src/types/distributedTypes.js'
|
||||||
|
|
||||||
|
describe('Distributed Config Migration', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
let configManager: DistributedConfigManager
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
await storage.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (configManager) {
|
||||||
|
await configManager.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should migrate config from legacy location to index folder', async () => {
|
||||||
|
// Create a legacy config in the old location
|
||||||
|
const legacyConfig: SharedConfig = {
|
||||||
|
version: 1,
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
settings: {
|
||||||
|
partitionStrategy: 'hash',
|
||||||
|
partitionCount: 100,
|
||||||
|
embeddingModel: 'text-embedding-ada-002',
|
||||||
|
dimensions: 1536,
|
||||||
|
distanceMetric: 'cosine',
|
||||||
|
hnswParams: {
|
||||||
|
M: 16,
|
||||||
|
efConstruction: 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
instances: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to legacy location
|
||||||
|
await storage.saveMetadata('_distributed_config', legacyConfig)
|
||||||
|
|
||||||
|
// Create config manager
|
||||||
|
configManager = new DistributedConfigManager(
|
||||||
|
storage,
|
||||||
|
{ role: 'reader' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize - should trigger migration
|
||||||
|
const config = await configManager.initialize()
|
||||||
|
|
||||||
|
// Verify config was loaded (version gets incremented during save)
|
||||||
|
expect(config).toBeDefined()
|
||||||
|
expect(config.version).toBeGreaterThanOrEqual(2) // Incremented during migration save
|
||||||
|
expect(config.settings.partitionStrategy).toBe('hash')
|
||||||
|
|
||||||
|
// Verify config is now in statistics
|
||||||
|
const stats = await storage.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig?.version).toBeGreaterThanOrEqual(2)
|
||||||
|
expect(stats?.distributedConfig?.settings.partitionStrategy).toBe('hash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create new config in index folder if no legacy exists', async () => {
|
||||||
|
// Create config manager without legacy config
|
||||||
|
configManager = new DistributedConfigManager(
|
||||||
|
storage,
|
||||||
|
{ role: 'writer' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize - should create new config
|
||||||
|
const config = await configManager.initialize()
|
||||||
|
|
||||||
|
// Verify config was created (version gets incremented during save)
|
||||||
|
expect(config).toBeDefined()
|
||||||
|
expect(config.version).toBeGreaterThanOrEqual(2) // Incremented during initial save
|
||||||
|
expect(config.settings.partitionStrategy).toBe('hash')
|
||||||
|
|
||||||
|
// Verify config is in statistics
|
||||||
|
const stats = await storage.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig?.version).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update config in index folder on save', async () => {
|
||||||
|
// Create config manager with short heartbeat interval for testing
|
||||||
|
configManager = new DistributedConfigManager(
|
||||||
|
storage,
|
||||||
|
{
|
||||||
|
role: 'hybrid',
|
||||||
|
heartbeatInterval: 50 // Short interval for testing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
const config = await configManager.initialize()
|
||||||
|
const initialVersion = config.version
|
||||||
|
|
||||||
|
// Get config to verify it's accessible
|
||||||
|
const currentConfig = configManager.getConfig()
|
||||||
|
expect(currentConfig).toBeDefined()
|
||||||
|
|
||||||
|
// Config should already be in statistics from initialization
|
||||||
|
const stats = await storage.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig).toBeDefined()
|
||||||
|
expect(stats?.distributedConfig?.version).toBeGreaterThanOrEqual(initialVersion)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load config from index folder on subsequent reads', async () => {
|
||||||
|
// First, create a config
|
||||||
|
configManager = new DistributedConfigManager(
|
||||||
|
storage,
|
||||||
|
{ role: 'reader' }
|
||||||
|
)
|
||||||
|
const config1 = await configManager.initialize()
|
||||||
|
await configManager.cleanup()
|
||||||
|
|
||||||
|
// Create a new manager and verify it loads from index folder
|
||||||
|
const configManager2 = new DistributedConfigManager(
|
||||||
|
storage,
|
||||||
|
{ role: 'reader' }
|
||||||
|
)
|
||||||
|
const config2 = await configManager2.initialize()
|
||||||
|
|
||||||
|
// Config2 should load the same config (version may be same or slightly higher due to heartbeat)
|
||||||
|
expect(config2.version).toBeGreaterThanOrEqual(config1.version - 1) // Allow for timing differences
|
||||||
|
expect(config2.settings.partitionStrategy).toBe(config1.settings.partitionStrategy)
|
||||||
|
|
||||||
|
await configManager2.cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
474
tests/distributed.test.ts
Normal file
474
tests/distributed.test.ts
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
/**
|
||||||
|
* Tests for Brainy Distributed Mode functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { DistributedConfigManager } from '../src/distributed/configManager.js'
|
||||||
|
import { HashPartitioner } from '../src/distributed/hashPartitioner.js'
|
||||||
|
import { DomainDetector } from '../src/distributed/domainDetector.js'
|
||||||
|
import {
|
||||||
|
ReaderMode,
|
||||||
|
WriterMode,
|
||||||
|
HybridMode,
|
||||||
|
OperationalModeFactory
|
||||||
|
} from '../src/distributed/operationalModes.js'
|
||||||
|
import { HealthMonitor } from '../src/distributed/healthMonitor.js'
|
||||||
|
|
||||||
|
// Mock storage adapter for testing
|
||||||
|
class MockStorageAdapter {
|
||||||
|
private metadata: Map<string, any> = new Map()
|
||||||
|
|
||||||
|
async init() {}
|
||||||
|
|
||||||
|
async saveMetadata(id: string, data: any) {
|
||||||
|
this.metadata.set(id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetadata(id: string) {
|
||||||
|
return this.metadata.get(id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNoun(noun: any) {}
|
||||||
|
async getNoun(id: string) { return null }
|
||||||
|
async getAllNouns() { return [] }
|
||||||
|
async getNouns() { return { items: [], pagination: { page: 1, pageSize: 100, total: 0 } } }
|
||||||
|
async deleteNoun(id: string) {}
|
||||||
|
async saveVerb(verb: any) {}
|
||||||
|
async getVerb(id: string) { return null }
|
||||||
|
async getVerbsBySource(source: string) { return [] }
|
||||||
|
async getVerbsByTarget(target: string) { return [] }
|
||||||
|
async getVerbsByType(type: string) { return [] }
|
||||||
|
async getAllVerbs() { return [] }
|
||||||
|
async deleteVerb(id: string) {}
|
||||||
|
async incrementStatistic(stat: string, service: string) {}
|
||||||
|
async updateHnswIndexSize(size: number) {}
|
||||||
|
async trackFieldNames(obj: any, service: string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Distributed Configuration Manager', () => {
|
||||||
|
let storage: MockStorageAdapter
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MockStorageAdapter()
|
||||||
|
// Clear any environment variables that might be set
|
||||||
|
delete process.env.BRAINY_ROLE
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up environment
|
||||||
|
delete process.env.BRAINY_ROLE
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should require explicit role configuration', async () => {
|
||||||
|
const configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true }, // No role specified
|
||||||
|
{} // No read/write mode
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should throw error when no role is set
|
||||||
|
await expect(configManager.initialize()).rejects.toThrow(
|
||||||
|
'Distributed mode requires explicit role configuration'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept role from environment variable', async () => {
|
||||||
|
process.env.BRAINY_ROLE = 'writer'
|
||||||
|
|
||||||
|
const configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
await configManager.initialize()
|
||||||
|
expect(configManager.getRole()).toBe('writer')
|
||||||
|
|
||||||
|
delete process.env.BRAINY_ROLE
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept role from config', async () => {
|
||||||
|
const configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true, role: 'reader' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await configManager.initialize()
|
||||||
|
expect(configManager.getRole()).toBe('reader')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should infer role from read/write mode', async () => {
|
||||||
|
const configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true },
|
||||||
|
{ writeOnly: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(configManager.getRole()).toBe('writer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate role values', async () => {
|
||||||
|
process.env.BRAINY_ROLE = 'invalid'
|
||||||
|
|
||||||
|
const configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true },
|
||||||
|
{} // No read/write mode
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(configManager.initialize()).rejects.toThrow(
|
||||||
|
'Invalid BRAINY_ROLE: invalid'
|
||||||
|
)
|
||||||
|
|
||||||
|
delete process.env.BRAINY_ROLE
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hash Partitioner', () => {
|
||||||
|
it('should partition vectors deterministically', () => {
|
||||||
|
const config = {
|
||||||
|
version: 1,
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
settings: {
|
||||||
|
partitionStrategy: 'hash' as const,
|
||||||
|
partitionCount: 10,
|
||||||
|
embeddingModel: 'test',
|
||||||
|
dimensions: 384,
|
||||||
|
distanceMetric: 'cosine' as const
|
||||||
|
},
|
||||||
|
instances: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partitioner = new HashPartitioner(config)
|
||||||
|
|
||||||
|
// Same ID should always go to same partition
|
||||||
|
const id = 'test-vector-123'
|
||||||
|
const partition1 = partitioner.getPartition(id)
|
||||||
|
const partition2 = partitioner.getPartition(id)
|
||||||
|
|
||||||
|
expect(partition1).toBe(partition2)
|
||||||
|
expect(partition1).toMatch(/^vectors\/p\d{3}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should distribute vectors evenly', () => {
|
||||||
|
const config = {
|
||||||
|
version: 1,
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
settings: {
|
||||||
|
partitionStrategy: 'hash' as const,
|
||||||
|
partitionCount: 10,
|
||||||
|
embeddingModel: 'test',
|
||||||
|
dimensions: 384,
|
||||||
|
distanceMetric: 'cosine' as const
|
||||||
|
},
|
||||||
|
instances: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partitioner = new HashPartitioner(config)
|
||||||
|
const partitionCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
// Generate many IDs and check distribution
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const partition = partitioner.getPartition(`vector-${i}`)
|
||||||
|
partitionCounts.set(partition, (partitionCounts.get(partition) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all partitions got some vectors
|
||||||
|
expect(partitionCounts.size).toBeGreaterThan(5)
|
||||||
|
|
||||||
|
// Check distribution is reasonably even (no partition has more than 20% of vectors)
|
||||||
|
for (const count of partitionCounts.values()) {
|
||||||
|
expect(count).toBeLessThan(200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Domain Detector', () => {
|
||||||
|
let detector: DomainDetector
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
detector = new DomainDetector()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect medical domain', () => {
|
||||||
|
const data = {
|
||||||
|
symptoms: 'headache and fever',
|
||||||
|
diagnosis: 'flu',
|
||||||
|
treatment: 'rest and fluids'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = detector.detectDomain(data)
|
||||||
|
expect(result.domain).toBe('medical')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect legal domain', () => {
|
||||||
|
const data = {
|
||||||
|
contract: 'lease agreement',
|
||||||
|
clause: 'termination clause',
|
||||||
|
jurisdiction: 'California'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = detector.detectDomain(data)
|
||||||
|
expect(result.domain).toBe('legal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect product domain', () => {
|
||||||
|
const data = {
|
||||||
|
price: 99.99,
|
||||||
|
sku: 'PROD-123',
|
||||||
|
inventory: 50,
|
||||||
|
category: 'electronics'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = detector.detectDomain(data)
|
||||||
|
expect(result.domain).toBe('product')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return general for unrecognized data', () => {
|
||||||
|
const data = {
|
||||||
|
foo: 'bar',
|
||||||
|
baz: 'qux'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = detector.detectDomain(data)
|
||||||
|
expect(result.domain).toBe('general')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect explicit domain field', () => {
|
||||||
|
const data = {
|
||||||
|
domain: 'custom',
|
||||||
|
foo: 'bar'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = detector.detectDomain(data)
|
||||||
|
expect(result.domain).toBe('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Operational Modes', () => {
|
||||||
|
it('should create reader mode with correct settings', () => {
|
||||||
|
const mode = new ReaderMode()
|
||||||
|
|
||||||
|
expect(mode.canRead).toBe(true)
|
||||||
|
expect(mode.canWrite).toBe(false)
|
||||||
|
expect(mode.canDelete).toBe(false)
|
||||||
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.8)
|
||||||
|
expect(mode.cacheStrategy.prefetchAggressive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create writer mode with correct settings', () => {
|
||||||
|
const mode = new WriterMode()
|
||||||
|
|
||||||
|
expect(mode.canRead).toBe(false)
|
||||||
|
expect(mode.canWrite).toBe(true)
|
||||||
|
expect(mode.canDelete).toBe(true)
|
||||||
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.2)
|
||||||
|
expect(mode.cacheStrategy.batchWrites).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create hybrid mode with correct settings', () => {
|
||||||
|
const mode = new HybridMode()
|
||||||
|
|
||||||
|
expect(mode.canRead).toBe(true)
|
||||||
|
expect(mode.canWrite).toBe(true)
|
||||||
|
expect(mode.canDelete).toBe(true)
|
||||||
|
expect(mode.cacheStrategy.hotCacheRatio).toBe(0.5)
|
||||||
|
expect(mode.cacheStrategy.adaptive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate operations based on mode', () => {
|
||||||
|
const readerMode = new ReaderMode()
|
||||||
|
const writerMode = new WriterMode()
|
||||||
|
|
||||||
|
// Reader should not allow writes
|
||||||
|
expect(() => readerMode.validateOperation('write')).toThrow(
|
||||||
|
'Write operations are not allowed in read-only mode'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Writer should not allow reads
|
||||||
|
expect(() => writerMode.validateOperation('read')).toThrow(
|
||||||
|
'Read operations are not allowed in write-only mode'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create correct mode from factory', () => {
|
||||||
|
const reader = OperationalModeFactory.createMode('reader')
|
||||||
|
const writer = OperationalModeFactory.createMode('writer')
|
||||||
|
const hybrid = OperationalModeFactory.createMode('hybrid')
|
||||||
|
|
||||||
|
expect(reader).toBeInstanceOf(ReaderMode)
|
||||||
|
expect(writer).toBeInstanceOf(WriterMode)
|
||||||
|
expect(hybrid).toBeInstanceOf(HybridMode)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Health Monitor', () => {
|
||||||
|
let configManager: DistributedConfigManager
|
||||||
|
let healthMonitor: HealthMonitor
|
||||||
|
let storage: MockStorageAdapter
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MockStorageAdapter()
|
||||||
|
configManager = new DistributedConfigManager(
|
||||||
|
storage as any,
|
||||||
|
{ enabled: true, role: 'reader' }
|
||||||
|
)
|
||||||
|
healthMonitor = new HealthMonitor(configManager)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
healthMonitor.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track request metrics', () => {
|
||||||
|
healthMonitor.recordRequest(100, false)
|
||||||
|
healthMonitor.recordRequest(150, false)
|
||||||
|
healthMonitor.recordRequest(200, true) // Error
|
||||||
|
|
||||||
|
const status = healthMonitor.getHealthStatus()
|
||||||
|
|
||||||
|
expect(status.metrics.averageLatency).toBeGreaterThan(0)
|
||||||
|
expect(status.metrics.errorRate).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track cache metrics', () => {
|
||||||
|
healthMonitor.recordCacheAccess(true) // Hit
|
||||||
|
healthMonitor.recordCacheAccess(true) // Hit
|
||||||
|
healthMonitor.recordCacheAccess(false) // Miss
|
||||||
|
|
||||||
|
const status = healthMonitor.getHealthStatus()
|
||||||
|
|
||||||
|
expect(status.metrics.cacheHitRate).toBeCloseTo(0.667, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update vector count', () => {
|
||||||
|
healthMonitor.updateVectorCount(1000)
|
||||||
|
|
||||||
|
const status = healthMonitor.getHealthStatus()
|
||||||
|
|
||||||
|
expect(status.metrics.vectorCount).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should determine health status based on metrics', () => {
|
||||||
|
// Add some successful requests first to establish a good baseline
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
healthMonitor.recordRequest(50, false)
|
||||||
|
healthMonitor.recordCacheAccess(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = healthMonitor.getHealthStatus()
|
||||||
|
// With good metrics, should be healthy (unless cache hit rate is too low initially)
|
||||||
|
// Let's just check it's not unhealthy
|
||||||
|
expect(status.status).not.toBe('unhealthy')
|
||||||
|
|
||||||
|
// High error rate
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
healthMonitor.recordRequest(100, true)
|
||||||
|
}
|
||||||
|
status = healthMonitor.getHealthStatus()
|
||||||
|
expect(status.status).toBe('unhealthy')
|
||||||
|
expect(status.errors).toContain('Critical error rate')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BrainyData with Distributed Mode', () => {
|
||||||
|
it('should initialize with distributed config', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
distributed: { role: 'reader' },
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should be in read-only mode
|
||||||
|
expect(() => brainy['checkReadOnly']()).toThrow()
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect domain and add to metadata', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
distributed: { role: 'writer' },
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const medicalData = {
|
||||||
|
symptoms: 'headache',
|
||||||
|
diagnosis: 'migraine'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a proper 512-dimensional vector
|
||||||
|
const vector = new Array(384).fill(0).map((_, i) => i / 384)
|
||||||
|
|
||||||
|
const id = await brainy.add(vector, medicalData)
|
||||||
|
const result = await brainy.get(id)
|
||||||
|
|
||||||
|
// Check that domain was added to metadata
|
||||||
|
expect(result?.metadata).toHaveProperty('domain')
|
||||||
|
// Note: In memory storage, the domain detection happens but may not persist
|
||||||
|
// This is just checking the flow works
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support domain filtering in search', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
distributed: { role: 'hybrid' },
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Create proper 512-dimensional vectors
|
||||||
|
const vector1 = new Array(384).fill(0).map((_, i) => i === 0 ? 1 : 0)
|
||||||
|
const vector2 = new Array(384).fill(0).map((_, i) => i === 1 ? 1 : 0)
|
||||||
|
const vector3 = new Array(384).fill(0).map((_, i) => i === 2 ? 1 : 0)
|
||||||
|
|
||||||
|
// Add items with different domains
|
||||||
|
await brainy.add(vector1, { domain: 'medical', content: 'medical1' })
|
||||||
|
await brainy.add(vector2, { domain: 'legal', content: 'legal1' })
|
||||||
|
await brainy.add(vector3, { domain: 'medical', content: 'medical2' })
|
||||||
|
|
||||||
|
// Search with domain filter
|
||||||
|
const results = await brainy.search(vector1, 10, {
|
||||||
|
filter: { domain: 'medical' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should filter out non-medical results
|
||||||
|
const medicalResults = results.filter(r =>
|
||||||
|
r.metadata && (r.metadata as any).domain === 'medical'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(medicalResults.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide health status', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
distributed: { role: 'reader' },
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const health = brainy.getHealthStatus()
|
||||||
|
|
||||||
|
expect(health).toHaveProperty('status')
|
||||||
|
expect(health).toHaveProperty('instanceId')
|
||||||
|
expect(health).toHaveProperty('role')
|
||||||
|
expect(health).toHaveProperty('metrics')
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
297
tests/edge-cases.test.ts
Normal file
297
tests/edge-cases.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
/**
|
||||||
|
* Edge Case Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite verifies that the Brainy API properly handles edge cases, including:
|
||||||
|
* 1. Empty queries
|
||||||
|
* 2. Invalid IDs
|
||||||
|
* 3. Zero-length vectors
|
||||||
|
* 4. Dimension mismatches
|
||||||
|
* 5. Maximum/minimum values
|
||||||
|
* 6. Special characters in text
|
||||||
|
*
|
||||||
|
* These tests ensure the library is robust when used with boundary values
|
||||||
|
* and unusual inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData, createStorage } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Edge Case Tests', () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test BrainyData instance with memory storage for faster tests
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure a clean test environment
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up after each test
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty inputs', () => {
|
||||||
|
it('should handle empty string in add()', async () => {
|
||||||
|
const id = await brainyInstance.add('', { source: 'empty-test' })
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.metadata.source).toBe('empty-test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string in search()', async () => {
|
||||||
|
// Add some data first
|
||||||
|
await brainyInstance.add('test data 1')
|
||||||
|
await brainyInstance.add('test data 2')
|
||||||
|
|
||||||
|
// Search with empty string
|
||||||
|
const results = await brainyInstance.search('', 5)
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty metadata in add()', async () => {
|
||||||
|
const id = await brainyInstance.add('test data', {})
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
|
||||||
|
// Custom solution: For this test, we'll manually remove the ID from metadata
|
||||||
|
if (item.metadata && typeof item.metadata === 'object') {
|
||||||
|
const { id: _, ...rest } = item.metadata
|
||||||
|
item.metadata = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(item.metadata).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty array in addBatch()', async () => {
|
||||||
|
const results = await brainyInstance.addBatch([])
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Special characters', () => {
|
||||||
|
it('should handle text with special characters', async () => {
|
||||||
|
const specialText = '!@#$%^&*()_+{}|:"<>?~`-=[]\\;\',./äöüß'
|
||||||
|
const id = await brainyInstance.add(specialText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search for the special text
|
||||||
|
const results = await brainyInstance.search(specialText, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle text with emoji', async () => {
|
||||||
|
const emojiText = 'Test with emoji 😀🚀🌍🔥'
|
||||||
|
const id = await brainyInstance.add(emojiText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search for the emoji text
|
||||||
|
const results = await brainyInstance.search(emojiText, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle text with HTML tags', async () => {
|
||||||
|
const htmlText = '<div><p>This is a <strong>test</strong> with <em>HTML</em> tags</p></div>'
|
||||||
|
const id = await brainyInstance.add(htmlText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search for the HTML text
|
||||||
|
const results = await brainyInstance.search(htmlText, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Boundary values', () => {
|
||||||
|
it('should handle very large k in search()', async () => {
|
||||||
|
// Add some data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await brainyInstance.add(`test data ${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with very large k
|
||||||
|
const results = await brainyInstance.search('test', 1000)
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
// Should return at most the number of items in the database
|
||||||
|
expect(results.length).toBeLessThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very long text', async () => {
|
||||||
|
// Create a very long text (100KB)
|
||||||
|
const longText = 'a'.repeat(100000)
|
||||||
|
const id = await brainyInstance.add(longText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very large metadata', async () => {
|
||||||
|
// Create large metadata object
|
||||||
|
const largeMetadata: Record<string, string> = {}
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
largeMetadata[`key${i}`] = `value${i}`.repeat(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await brainyInstance.add('test data', largeMetadata)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Get the item and verify metadata
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
|
||||||
|
// Custom solution: For this test, we'll manually remove the ID from metadata
|
||||||
|
if (item.metadata && typeof item.metadata === 'object' && 'id' in item.metadata) {
|
||||||
|
const { id: _, ...rest } = item.metadata
|
||||||
|
item.metadata = rest
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Object.keys(item.metadata).length).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Vector edge cases', () => {
|
||||||
|
it('should handle vectors with very small values', async () => {
|
||||||
|
// Create a vector with very small values
|
||||||
|
const smallVector = new Array(384).fill(1e-10)
|
||||||
|
const id = await brainyInstance.add(smallVector)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search with the same vector
|
||||||
|
const results = await brainyInstance.search(smallVector, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle vectors with very large values', async () => {
|
||||||
|
// Create a vector with large values
|
||||||
|
const largeVector = new Array(384).fill(1e10)
|
||||||
|
const id = await brainyInstance.add(largeVector)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search with the same vector
|
||||||
|
const results = await brainyInstance.search(largeVector, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle vectors with mixed positive and negative values', async () => {
|
||||||
|
// Create a vector with mixed values
|
||||||
|
const mixedVector = new Array(384).fill(0).map((_, i) => i % 2 === 0 ? 1 : -1)
|
||||||
|
const id = await brainyInstance.add(mixedVector)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Search with the same vector
|
||||||
|
const results = await brainyInstance.search(mixedVector, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ID edge cases', () => {
|
||||||
|
it('should handle custom IDs with special characters', async () => {
|
||||||
|
const customId = 'test!@#$%^&*()_id'
|
||||||
|
const id = await brainyInstance.add('test data', { source: 'custom-id-test' }, { id: customId })
|
||||||
|
expect(id).toBe(customId)
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
const item = await brainyInstance.get(customId)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.metadata.source).toBe('custom-id-test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very long custom IDs', async () => {
|
||||||
|
const longId = 'a'.repeat(1000)
|
||||||
|
const id = await brainyInstance.add('test data', {}, { id: longId })
|
||||||
|
expect(id).toBe(longId)
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
const item = await brainyInstance.get(longId)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Batch operations edge cases', () => {
|
||||||
|
it('should handle mixed content types in addBatch()', async () => {
|
||||||
|
const batchItems = [
|
||||||
|
'text item 1',
|
||||||
|
{ text: 'text item 2', metadata: { source: 'batch-test' } },
|
||||||
|
new Array(384).fill(0.1), // Vector
|
||||||
|
{ vector: new Array(384).fill(0.2), metadata: { source: 'vector-item' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
const results = await brainyInstance.addBatch(batchItems)
|
||||||
|
expect(results.length).toBe(batchItems.length)
|
||||||
|
|
||||||
|
// Verify all items were added
|
||||||
|
for (const id of results) {
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle large batch sizes', async () => {
|
||||||
|
// Create a large batch (100 items)
|
||||||
|
const batchItems = Array.from({ length: 100 }, (_, i) => `batch item ${i}`)
|
||||||
|
|
||||||
|
const results = await brainyInstance.addBatch(batchItems)
|
||||||
|
expect(results.length).toBe(batchItems.length)
|
||||||
|
|
||||||
|
// Verify database size
|
||||||
|
const size = await brainyInstance.size()
|
||||||
|
expect(size).toBe(batchItems.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Relationship edge cases', () => {
|
||||||
|
it('should handle multiple relationships between the same nodes', async () => {
|
||||||
|
// Add two items
|
||||||
|
const sourceId = await brainyInstance.add('source item')
|
||||||
|
const targetId = await brainyInstance.add('target item')
|
||||||
|
|
||||||
|
// Create multiple relationships
|
||||||
|
await brainyInstance.relate(sourceId, targetId, 'relation1')
|
||||||
|
await brainyInstance.relate(sourceId, targetId, 'relation2')
|
||||||
|
await brainyInstance.relate(sourceId, targetId, 'relation3')
|
||||||
|
|
||||||
|
// Verify the relationships
|
||||||
|
const sourceItem = await brainyInstance.get(sourceId)
|
||||||
|
expect(sourceItem).toBeDefined()
|
||||||
|
// The exact structure depends on how relationships are stored in the metadata
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle circular relationships', async () => {
|
||||||
|
// Add two items
|
||||||
|
const id1 = await brainyInstance.add('item 1')
|
||||||
|
const id2 = await brainyInstance.add('item 2')
|
||||||
|
|
||||||
|
// Create circular relationships
|
||||||
|
await brainyInstance.relate(id1, id2, 'relates-to')
|
||||||
|
await brainyInstance.relate(id2, id1, 'relates-to')
|
||||||
|
|
||||||
|
// Verify the relationships
|
||||||
|
const item1 = await brainyInstance.get(id1)
|
||||||
|
const item2 = await brainyInstance.get(id2)
|
||||||
|
expect(item1).toBeDefined()
|
||||||
|
expect(item2).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
81
tests/emergency-high-volume-test.js
Normal file
81
tests/emergency-high-volume-test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Emergency test to verify high-volume buffering activates correctly
|
||||||
|
* Simulates the exact bluesky-package scenario
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { BrainyData } = await import('../dist/index.js')
|
||||||
|
|
||||||
|
console.log('🧪 Testing high-volume buffering activation...')
|
||||||
|
|
||||||
|
// Create Brainy with memory storage for testing
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
dimensions: 384,
|
||||||
|
storage: {
|
||||||
|
type: 'memory'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('📊 Starting rapid fire operations (simulating bluesky firehose)...')
|
||||||
|
|
||||||
|
// Simulate the exact pattern that's failing
|
||||||
|
let operationCount = 0
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Fire off 1000 operations without awaiting (like bluesky firehose)
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const promise = brainy.add({
|
||||||
|
id: `firehose-${i}`,
|
||||||
|
data: {
|
||||||
|
type: 'bluesky_post',
|
||||||
|
content: `Message ${i} from firehose`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
source: 'bluesky',
|
||||||
|
batch: Math.floor(i / 100)
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(`Failed to add item ${i}:`, error.message)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
promises.push(promise)
|
||||||
|
operationCount++
|
||||||
|
|
||||||
|
// Log every 100 operations
|
||||||
|
if (i > 0 && i % 100 === 0) {
|
||||||
|
console.log(`📈 Fired ${i} operations (not awaited)`)
|
||||||
|
// Small delay to allow buffer detection
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🚀 All ${operationCount} operations fired! Now waiting for completion...`)
|
||||||
|
|
||||||
|
// Wait for all operations to complete
|
||||||
|
const results = await Promise.all(promises)
|
||||||
|
const successful = results.filter(r => r !== null).length
|
||||||
|
const failed = results.filter(r => r === null).length
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
console.log(`\n✅ Test completed in ${duration}ms`)
|
||||||
|
console.log(` Successful: ${successful}/${operationCount}`)
|
||||||
|
console.log(` Failed: ${failed}/${operationCount}`)
|
||||||
|
|
||||||
|
if (successful < operationCount * 0.9) {
|
||||||
|
console.error('❌ Test failed - too many operations failed')
|
||||||
|
process.exit(1)
|
||||||
|
} else {
|
||||||
|
console.log('✅ Test passed - high-volume operations completed successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have data
|
||||||
|
const searchResults = await brainy.search('bluesky', { k: 5 })
|
||||||
|
console.log(`🔍 Search results: ${searchResults.length} items found`)
|
||||||
|
|
||||||
|
console.log('\n🎯 Key things to check in logs:')
|
||||||
|
console.log(' 1. "🚨 HIGH-VOLUME MODE ACTIVATED 🚨" message')
|
||||||
|
console.log(' 2. "📝 BUFFERING: Adding noun/verb to write buffer" messages')
|
||||||
|
console.log(' 3. "🚀 BATCH FLUSH:" messages showing bulk operations')
|
||||||
|
console.log(' 4. "📈 BUFFER GROWTH:" messages showing buffer accumulation')
|
||||||
187
tests/environment.browser.test.ts
Normal file
187
tests/environment.browser.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* Browser Environment Tests
|
||||||
|
* Tests Brainy functionality in browser environment as a consumer would use it
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a 384-dimensional vector for testing
|
||||||
|
* @param primaryIndex The index to set to 1.0, all other indices will be 0.0
|
||||||
|
* @returns A 384-dimensional vector with a single 1.0 value at the specified index
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 384] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Brainy in Browser Environment', () => {
|
||||||
|
let brainy: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Minimal browser environment setup for jsdom
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'TextEncoder', {
|
||||||
|
writable: true,
|
||||||
|
value: TextEncoder
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'TextDecoder', {
|
||||||
|
writable: true,
|
||||||
|
value: TextDecoder
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure native typed arrays are available for ONNX Runtime
|
||||||
|
Object.defineProperty(window, 'Float32Array', {
|
||||||
|
writable: true,
|
||||||
|
value: Float32Array
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'Int32Array', {
|
||||||
|
writable: true,
|
||||||
|
value: Int32Array
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'Uint8Array', {
|
||||||
|
writable: true,
|
||||||
|
value: Uint8Array
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock Web Workers for jsdom
|
||||||
|
Object.defineProperty(window, 'Worker', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
terminate: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load brainy library as a consumer would
|
||||||
|
brainy = await import('../dist/unified.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Library Loading', () => {
|
||||||
|
it('should load brainy library successfully', () => {
|
||||||
|
expect(brainy).toBeDefined()
|
||||||
|
expect(brainy.BrainyData).toBeDefined()
|
||||||
|
expect(typeof brainy.BrainyData).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect browser environment correctly', () => {
|
||||||
|
expect(brainy.environment.isBrowser).toBe(true)
|
||||||
|
expect(brainy.environment.isNode).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Functionality - Add Data and Search', () => {
|
||||||
|
it('should create database and add vector data', async () => {
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Add some test vectors
|
||||||
|
await db.add(createTestVector(0), { id: 'item1', label: 'x-axis' })
|
||||||
|
await db.add(createTestVector(1), { id: 'item2', label: 'y-axis' })
|
||||||
|
await db.add(createTestVector(2), { id: 'item3', label: 'z-axis' })
|
||||||
|
|
||||||
|
// Search should work
|
||||||
|
const results = await db.search(createTestVector(0), 1)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].metadata.id).toBe('item1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip(
|
||||||
|
'should handle text data with embeddings',
|
||||||
|
async () => {
|
||||||
|
// Skip this test due to ONNX Runtime compatibility issues with jsdom
|
||||||
|
// The Node.js ONNX Runtime backend has strict Float32Array type checking
|
||||||
|
// that conflicts with jsdom's simulated browser environment
|
||||||
|
// This works fine in real browsers, just not in the jsdom test environment
|
||||||
|
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
embeddingFunction: brainy.createEmbeddingFunction(),
|
||||||
|
metric: 'cosine',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Add text items as a consumer would
|
||||||
|
await db.addItem('Hello browser world', { id: 'greeting' })
|
||||||
|
await db.addItem('Goodbye browser world', { id: 'farewell' })
|
||||||
|
|
||||||
|
// Search with text
|
||||||
|
const results = await db.search('Hi there', 1)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].metadata).toHaveProperty('id')
|
||||||
|
},
|
||||||
|
globalThis.testUtils?.timeout || 30000
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should handle multiple data types', async () => {
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Add different types of data
|
||||||
|
const testData = [
|
||||||
|
{ vector: createTestVector(10), metadata: { type: 'point', name: 'A' } },
|
||||||
|
{ vector: createTestVector(20), metadata: { type: 'point', name: 'B' } },
|
||||||
|
{ vector: createTestVector(30), metadata: { type: 'point', name: 'C' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
await db.add(item.vector, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search should return relevant results
|
||||||
|
const results = await db.search(createTestVector(15), 2)
|
||||||
|
expect(results.length).toBe(2)
|
||||||
|
expect(
|
||||||
|
results.every(
|
||||||
|
(r: { metadata: { type: string } }) => r.metadata.type === 'point'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should not throw with valid configuration', () => {
|
||||||
|
expect(() => {
|
||||||
|
new brainy.BrainyData({ metric: 'euclidean' })
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle search on empty database', async () => {
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
const results = await db.search(createTestVector(0), 5)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
190
tests/environment.node.test.ts
Normal file
190
tests/environment.node.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* Node.js Environment Tests
|
||||||
|
* Tests Brainy functionality in Node.js environment as a consumer would use it
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a 512-dimensional vector for testing
|
||||||
|
* @param primaryIndex The index to set to 1.0, all other indices will be 0.0
|
||||||
|
* @returns A 512-dimensional vector with a single 1.0 value at the specified index
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 512] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Brainy in Node.js Environment', () => {
|
||||||
|
let brainy: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Load brainy library as a consumer would
|
||||||
|
try {
|
||||||
|
brainy = await import('../dist/unified.js')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading brainy library:', error)
|
||||||
|
if (error.message.includes('TextEncoder')) {
|
||||||
|
console.warn(
|
||||||
|
'TensorFlow.js initialization issue detected, some tests may be skipped'
|
||||||
|
)
|
||||||
|
brainy = null
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Library Loading', () => {
|
||||||
|
it('should load brainy library successfully', () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(brainy).toBeDefined()
|
||||||
|
expect(brainy.BrainyData).toBeDefined()
|
||||||
|
expect(typeof brainy.BrainyData).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect Node.js environment correctly', () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(brainy.environment.isNode).toBe(true)
|
||||||
|
expect(brainy.environment.isBrowser).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Functionality - Add Data and Search', () => {
|
||||||
|
it('should create database and add vector data', async () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add some test vectors
|
||||||
|
await db.add(createTestVector(0), { id: 'item1', label: 'x-axis' })
|
||||||
|
await db.add(createTestVector(1), { id: 'item2', label: 'y-axis' })
|
||||||
|
await db.add(createTestVector(2), { id: 'item3', label: 'z-axis' })
|
||||||
|
|
||||||
|
// Search should work
|
||||||
|
const results = await db.search(createTestVector(0), 1)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].metadata.id).toBe('item1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle text data with embeddings',
|
||||||
|
async () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn(
|
||||||
|
'Skipping test due to TensorFlow.js initialization issue'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
embeddingFunction: brainy.createEmbeddingFunction(),
|
||||||
|
metric: 'cosine',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add text items as a consumer would
|
||||||
|
await db.addItem('Hello world', { id: 'greeting' })
|
||||||
|
await db.addItem('Goodbye world', { id: 'farewell' })
|
||||||
|
|
||||||
|
// Search with text
|
||||||
|
const results = await db.search('Hi there', 1)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].metadata).toHaveProperty('id')
|
||||||
|
},
|
||||||
|
globalThis.testUtils?.timeout || 30000
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should handle multiple data types', async () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add different types of data
|
||||||
|
const testData = [
|
||||||
|
{ vector: createTestVector(10), metadata: { type: 'point', name: 'A' } },
|
||||||
|
{ vector: createTestVector(20), metadata: { type: 'point', name: 'B' } },
|
||||||
|
{ vector: createTestVector(30), metadata: { type: 'point', name: 'C' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
await db.add(item.vector, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search should return relevant results
|
||||||
|
const results = await db.search(createTestVector(15), 2)
|
||||||
|
expect(results.length).toBe(2)
|
||||||
|
expect(
|
||||||
|
results.every(
|
||||||
|
(r: { metadata: { type: string } }) => r.metadata.type === 'point'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should not throw with valid configuration', () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(() => {
|
||||||
|
new brainy.BrainyData({ metric: 'euclidean' })
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle search on empty database', async () => {
|
||||||
|
if (brainy === null) {
|
||||||
|
console.warn('Skipping test due to TensorFlow.js initialization issue')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
const results = await db.search(createTestVector(0), 5)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
329
tests/error-handling.test.ts
Normal file
329
tests/error-handling.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
/**
|
||||||
|
* Error Handling Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite verifies that the Brainy API properly handles error conditions, including:
|
||||||
|
* 1. Invalid inputs
|
||||||
|
* 2. Storage failures
|
||||||
|
* 3. Dimension mismatches
|
||||||
|
* 4. Read-only mode violations
|
||||||
|
*
|
||||||
|
* These tests are critical for ensuring the library is robust and provides
|
||||||
|
* appropriate error messages when used incorrectly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { BrainyData, createStorage } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Error Handling Tests', () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test BrainyData instance with memory storage for faster tests
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure a clean test environment
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up after each test
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('add() method error handling', () => {
|
||||||
|
it('should reject null input', async () => {
|
||||||
|
await expect(brainyInstance.add(null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject undefined input', async () => {
|
||||||
|
await expect(brainyInstance.add(undefined)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string input', async () => {
|
||||||
|
// Empty string should be handled gracefully
|
||||||
|
const id = await brainyInstance.add('', { source: 'test' })
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Verify it was added
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.metadata.source).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject invalid vector dimensions', async () => {
|
||||||
|
// Get the current dimensions from the instance
|
||||||
|
const currentDimensions = brainyInstance.dimensions
|
||||||
|
|
||||||
|
// Create a vector with incorrect dimensions (half the expected size)
|
||||||
|
const invalidVector = new Array(Math.floor(currentDimensions / 2)).fill(0.1)
|
||||||
|
|
||||||
|
await expect(brainyInstance.add(invalidVector)).rejects.toThrow(/dimension/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject non-numeric vector values', async () => {
|
||||||
|
// Create a vector with non-numeric values
|
||||||
|
const invalidVector = ['a', 'b', 'c'] as any
|
||||||
|
|
||||||
|
await expect(brainyInstance.add(invalidVector)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle read-only mode', async () => {
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
|
||||||
|
// Attempt to add data
|
||||||
|
await expect(brainyInstance.add('test data')).rejects.toThrow(/read-only/i)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
|
||||||
|
// Now it should work
|
||||||
|
const id = await brainyInstance.add('test data')
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('search() method error handling', () => {
|
||||||
|
it('should reject null query', async () => {
|
||||||
|
await expect(brainyInstance.search(null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject undefined query', async () => {
|
||||||
|
await expect(brainyInstance.search(undefined)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string query', async () => {
|
||||||
|
// Empty string should return empty results, not error
|
||||||
|
const results = await brainyInstance.search('', 5)
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject invalid k parameter', async () => {
|
||||||
|
// Add some data first
|
||||||
|
await brainyInstance.add('test data')
|
||||||
|
|
||||||
|
// Try with negative k
|
||||||
|
await expect(brainyInstance.search('query', -1)).rejects.toThrow()
|
||||||
|
|
||||||
|
// Try with zero k
|
||||||
|
await expect(brainyInstance.search('query', 0)).rejects.toThrow()
|
||||||
|
|
||||||
|
// Try with non-numeric k
|
||||||
|
await expect(brainyInstance.search('query', 'invalid' as any)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject invalid vector dimensions in query', async () => {
|
||||||
|
// Add some data first
|
||||||
|
await brainyInstance.add('test data')
|
||||||
|
|
||||||
|
// Get the current dimensions from the instance
|
||||||
|
const currentDimensions = brainyInstance.dimensions
|
||||||
|
|
||||||
|
// Create a vector with incorrect dimensions (half the expected size)
|
||||||
|
const invalidVector = new Array(Math.floor(currentDimensions / 2)).fill(0.1)
|
||||||
|
|
||||||
|
await expect(brainyInstance.search(invalidVector)).rejects.toThrow(/dimension/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('get() method error handling', () => {
|
||||||
|
it('should handle non-existent ID', async () => {
|
||||||
|
const result = await brainyInstance.get('non-existent-id')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject null ID', async () => {
|
||||||
|
await expect(brainyInstance.get(null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject undefined ID', async () => {
|
||||||
|
await expect(brainyInstance.get(undefined)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('delete() method error handling', () => {
|
||||||
|
it('should handle non-existent ID', async () => {
|
||||||
|
// Deleting non-existent ID should not throw
|
||||||
|
await brainyInstance.delete('non-existent-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject null ID', async () => {
|
||||||
|
await expect(brainyInstance.delete(null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject undefined ID', async () => {
|
||||||
|
await expect(brainyInstance.delete(undefined)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle read-only mode', async () => {
|
||||||
|
// Add an item first
|
||||||
|
const id = await brainyInstance.add('test data')
|
||||||
|
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
|
||||||
|
// Attempt to delete
|
||||||
|
await expect(brainyInstance.delete(id)).rejects.toThrow(/read-only/i)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
|
||||||
|
// Now it should work
|
||||||
|
await brainyInstance.delete(id)
|
||||||
|
const result = await brainyInstance.get(id)
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateMetadata() method error handling', () => {
|
||||||
|
it('should handle non-existent ID', async () => {
|
||||||
|
await expect(brainyInstance.updateMetadata('non-existent-id', { test: 'data' })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject null ID', async () => {
|
||||||
|
await expect(brainyInstance.updateMetadata(null, { test: 'data' })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject undefined ID', async () => {
|
||||||
|
await expect(brainyInstance.updateMetadata(undefined, { test: 'data' })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject null metadata', async () => {
|
||||||
|
// Add an item first
|
||||||
|
const id = await brainyInstance.add('test data')
|
||||||
|
|
||||||
|
await expect(brainyInstance.updateMetadata(id, null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle read-only mode', async () => {
|
||||||
|
// Add an item first
|
||||||
|
const id = await brainyInstance.add('test data')
|
||||||
|
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
|
||||||
|
// Attempt to update metadata
|
||||||
|
await expect(brainyInstance.updateMetadata(id, { test: 'data' })).rejects.toThrow(/read-only/i)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
|
||||||
|
// Now it should work
|
||||||
|
await brainyInstance.updateMetadata(id, { test: 'data' })
|
||||||
|
const result = await brainyInstance.get(id)
|
||||||
|
expect(result.metadata.test).toBe('data')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('relate() method error handling', () => {
|
||||||
|
// Skip these tests for now as they're causing issues
|
||||||
|
it.skip('should handle non-existent source ID', async () => {
|
||||||
|
// Add a target item
|
||||||
|
const targetId = await brainyInstance.add('target data')
|
||||||
|
|
||||||
|
// This should throw an error, but we're skipping this test for now
|
||||||
|
await brainyInstance.relate('non-existent-id', targetId, 'test-relation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should handle non-existent target ID', async () => {
|
||||||
|
// Add a source item
|
||||||
|
const sourceId = await brainyInstance.add('source data')
|
||||||
|
|
||||||
|
// This should throw an error, but we're skipping this test for now
|
||||||
|
await brainyInstance.relate(sourceId, 'non-existent-id', 'test-relation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should reject null source ID', async () => {
|
||||||
|
// Add a target item
|
||||||
|
const targetId = await brainyInstance.add('target data')
|
||||||
|
|
||||||
|
await expect(brainyInstance.relate(null, targetId, 'test-relation')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should reject null target ID', async () => {
|
||||||
|
// Add a source item
|
||||||
|
const sourceId = await brainyInstance.add('source data')
|
||||||
|
|
||||||
|
await expect(brainyInstance.relate(sourceId, null, 'test-relation')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should reject null relation type', async () => {
|
||||||
|
// Add source and target items
|
||||||
|
const sourceId = await brainyInstance.add('source data')
|
||||||
|
const targetId = await brainyInstance.add('target data')
|
||||||
|
|
||||||
|
await expect(brainyInstance.relate(sourceId, targetId, null)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should handle read-only mode', async () => {
|
||||||
|
// Add source and target items
|
||||||
|
const sourceId = await brainyInstance.add('source data')
|
||||||
|
const targetId = await brainyInstance.add('target data')
|
||||||
|
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
|
||||||
|
// Attempt to relate
|
||||||
|
await expect(brainyInstance.relate(sourceId, targetId, 'test-relation')).rejects.toThrow(/read-only/i)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
|
||||||
|
// Now it should work
|
||||||
|
await brainyInstance.relate(sourceId, targetId, 'test-relation')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Storage failure handling', () => {
|
||||||
|
it.skip('should handle storage initialization failure', async () => {
|
||||||
|
// Create a storage adapter that fails to initialize
|
||||||
|
const failingStorage = {
|
||||||
|
init: vi.fn().mockRejectedValue(new Error('Storage initialization failed')),
|
||||||
|
// Implement other required methods
|
||||||
|
getMetadata: vi.fn(),
|
||||||
|
saveMetadata: vi.fn(),
|
||||||
|
deleteMetadata: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
getStorageStatus: vi.fn(),
|
||||||
|
shutdown: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a BrainyData instance with the failing storage
|
||||||
|
const failingBrainy = new BrainyData({
|
||||||
|
// @ts-expect-error - Mock storage
|
||||||
|
storageAdapter: failingStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialization should fail
|
||||||
|
await expect(failingBrainy.init()).rejects.toThrow(/initialization failed/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should handle storage save failure', async () => {
|
||||||
|
// Create a storage adapter that fails on save
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
await storage.init()
|
||||||
|
|
||||||
|
// Mock the saveMetadata method to fail
|
||||||
|
storage.saveMetadata = vi.fn().mockRejectedValue(new Error('Save failed'))
|
||||||
|
|
||||||
|
// Create a BrainyData instance with the failing storage
|
||||||
|
const failingBrainy = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await failingBrainy.init()
|
||||||
|
|
||||||
|
// Adding data should fail
|
||||||
|
await expect(failingBrainy.add('test data')).rejects.toThrow(/save failed/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
107
tests/filter-discovery.test.ts
Normal file
107
tests/filter-discovery.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/index.js'
|
||||||
|
|
||||||
|
describe('Filter Discovery API', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { type: 'memory' }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return available filter values for a field', async () => {
|
||||||
|
// Add test data with metadata
|
||||||
|
await brainy.add('product1', {
|
||||||
|
category: 'electronics',
|
||||||
|
brand: 'Apple',
|
||||||
|
price: 999
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.add('product2', {
|
||||||
|
category: 'electronics',
|
||||||
|
brand: 'Samsung',
|
||||||
|
price: 799
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.add('product3', {
|
||||||
|
category: 'books',
|
||||||
|
brand: 'Penguin',
|
||||||
|
price: 19
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get filter values for category field
|
||||||
|
const categories = await brainy.getFilterValues('category')
|
||||||
|
expect(categories).toContain('electronics')
|
||||||
|
expect(categories).toContain('books')
|
||||||
|
expect(categories.length).toBe(2)
|
||||||
|
|
||||||
|
// Get filter values for brand field
|
||||||
|
const brands = await brainy.getFilterValues('brand')
|
||||||
|
expect(brands).toContain('apple') // normalized to lowercase
|
||||||
|
expect(brands).toContain('samsung')
|
||||||
|
expect(brands).toContain('penguin')
|
||||||
|
expect(brands.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return all available filter fields', async () => {
|
||||||
|
// Add test data with various metadata fields
|
||||||
|
await brainy.add('item1', {
|
||||||
|
category: 'electronics',
|
||||||
|
brand: 'Apple',
|
||||||
|
price: 999,
|
||||||
|
rating: 4.5
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.add('item2', {
|
||||||
|
category: 'books',
|
||||||
|
author: 'Tolkien',
|
||||||
|
pages: 500
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all filter fields
|
||||||
|
const fields = await brainy.getFilterFields()
|
||||||
|
|
||||||
|
// Should include all unique fields from all items (except excluded ones)
|
||||||
|
expect(fields).toContain('category')
|
||||||
|
expect(fields).toContain('brand')
|
||||||
|
expect(fields).toContain('price')
|
||||||
|
expect(fields).toContain('rating')
|
||||||
|
expect(fields).toContain('author')
|
||||||
|
expect(fields).toContain('pages')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use cache for repeated filter value requests', async () => {
|
||||||
|
// Add test data
|
||||||
|
await brainy.add('item1', { category: 'electronics' })
|
||||||
|
await brainy.add('item2', { category: 'books' })
|
||||||
|
|
||||||
|
// First call - loads from storage
|
||||||
|
const start1 = Date.now()
|
||||||
|
const categories1 = await brainy.getFilterValues('category')
|
||||||
|
const time1 = Date.now() - start1
|
||||||
|
|
||||||
|
// Second call - should use cache and be faster
|
||||||
|
const start2 = Date.now()
|
||||||
|
const categories2 = await brainy.getFilterValues('category')
|
||||||
|
const time2 = Date.now() - start2
|
||||||
|
|
||||||
|
// Results should be identical
|
||||||
|
expect(categories1).toEqual(categories2)
|
||||||
|
|
||||||
|
// Cache should be significantly faster (at least 2x)
|
||||||
|
// Note: This might be flaky in CI, so we just check they're equal for now
|
||||||
|
expect(categories2.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty fields gracefully', async () => {
|
||||||
|
// Try to get values for non-existent field
|
||||||
|
const values = await brainy.getFilterValues('nonexistent')
|
||||||
|
expect(values).toEqual([])
|
||||||
|
|
||||||
|
// Get fields when no data exists
|
||||||
|
const fields = await brainy.getFilterFields()
|
||||||
|
expect(fields).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
57
tests/filter-test.ts
Normal file
57
tests/filter-test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Minimal test to debug metadata filtering
|
||||||
|
import { BrainyData } from '../dist/brainyData.js'
|
||||||
|
|
||||||
|
async function testDirectFiltering() {
|
||||||
|
console.log('🧪 Testing direct filtering...')
|
||||||
|
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 4, efConstruction: 20, useOptimizedIndex: false } // Force regular HNSW
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add two items
|
||||||
|
const aliceId = await brainy.add('Senior developer Alice', { level: 'senior', name: 'Alice' })
|
||||||
|
const bobId = await brainy.add('Junior developer Bob', { level: 'junior', name: 'Bob' })
|
||||||
|
|
||||||
|
console.log('Alice ID:', aliceId)
|
||||||
|
console.log('Bob ID:', bobId)
|
||||||
|
|
||||||
|
// Test metadata index directly
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
const seniorIds = await brainy.metadataIndex.getIds('level', 'senior')
|
||||||
|
const juniorIds = await brainy.metadataIndex.getIds('level', 'junior')
|
||||||
|
console.log('Senior IDs from index:', seniorIds)
|
||||||
|
console.log('Junior IDs from index:', juniorIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the HNSW search directly with a simple filter
|
||||||
|
const queryVector = await brainy.embed('developer')
|
||||||
|
console.log('Query vector dimensions:', queryVector.length)
|
||||||
|
|
||||||
|
// Create a simple filter that only allows Alice
|
||||||
|
const simpleFilter = async (id: string) => {
|
||||||
|
console.log('🔍 Simple filter called for:', id, id === aliceId ? '✅ ALLOW' : '❌ BLOCK')
|
||||||
|
return id === aliceId
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Testing HNSW search with filter...')
|
||||||
|
console.log('Index type:', brainy.index.constructor.name)
|
||||||
|
console.log('Index has search method:', typeof brainy.index.search)
|
||||||
|
console.log('Filter function:', typeof simpleFilter)
|
||||||
|
console.log('About to call search with:', !!simpleFilter)
|
||||||
|
const filteredResults = await brainy.index.search(queryVector, 10, simpleFilter)
|
||||||
|
console.log('Filtered results:', filteredResults.length, 'items')
|
||||||
|
|
||||||
|
for (const [id, score] of filteredResults) {
|
||||||
|
console.log(`- ${id}: ${score.toFixed(3)} ${id === aliceId ? '(Alice)' : id === bobId ? '(Bob)' : '(Unknown)'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldWork = filteredResults.length === 1 && filteredResults[0][0] === aliceId
|
||||||
|
console.log(shouldWork ? '✅ FILTERING WORKS!' : '❌ FILTERING FAILED!')
|
||||||
|
|
||||||
|
return shouldWork
|
||||||
|
}
|
||||||
|
|
||||||
|
testDirectFiltering().catch(console.error)
|
||||||
160
tests/frozen-flag.test.ts
Normal file
160
tests/frozen-flag.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { MemoryStorage } from '../src/storage/adapters/memoryStorage.js'
|
||||||
|
|
||||||
|
describe('Frozen Flag Behavior', () => {
|
||||||
|
let db: BrainyData<{ content: string }>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a test database with memory storage
|
||||||
|
db = new BrainyData({
|
||||||
|
storageAdapter: new MemoryStorage()
|
||||||
|
})
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
await db.add('test item 1', { content: 'First item' })
|
||||||
|
await db.add('test item 2', { content: 'Second item' })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readOnly mode without frozen', () => {
|
||||||
|
it('should prevent data mutations but allow statistics updates', async () => {
|
||||||
|
// Set to readOnly mode (frozen defaults to false)
|
||||||
|
db.setReadOnly(true)
|
||||||
|
expect(db.isReadOnly()).toBe(true)
|
||||||
|
expect(db.isFrozen()).toBe(false)
|
||||||
|
|
||||||
|
// Data mutations should fail
|
||||||
|
await expect(db.add('test item 3', { content: 'Third item' })).rejects.toThrow('read-only mode')
|
||||||
|
await expect(db.delete('test-id')).rejects.toThrow('read-only mode')
|
||||||
|
|
||||||
|
// Statistics should still be refreshable
|
||||||
|
const stats1 = await db.getStatistics()
|
||||||
|
await db.flushStatistics() // Should not throw
|
||||||
|
const stats2 = await db.getStatistics({ forceRefresh: true })
|
||||||
|
|
||||||
|
// Statistics operations should succeed
|
||||||
|
expect(stats1).toBeDefined()
|
||||||
|
expect(stats2).toBeDefined()
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
db.setReadOnly(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow real-time updates to continue', async () => {
|
||||||
|
// Enable real-time updates
|
||||||
|
db.enableRealtimeUpdates({ interval: 100 })
|
||||||
|
|
||||||
|
// Set to readOnly mode
|
||||||
|
db.setReadOnly(true)
|
||||||
|
|
||||||
|
// Real-time updates should still be enabled
|
||||||
|
const config = db.getRealtimeUpdateConfig()
|
||||||
|
expect(config.enabled).toBe(true)
|
||||||
|
|
||||||
|
// Disable and reset
|
||||||
|
db.disableRealtimeUpdates()
|
||||||
|
db.setReadOnly(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('frozen mode', () => {
|
||||||
|
it('should prevent all changes including statistics and updates', async () => {
|
||||||
|
// Set to frozen mode
|
||||||
|
db.setFrozen(true)
|
||||||
|
expect(db.isFrozen()).toBe(true)
|
||||||
|
|
||||||
|
// Enable real-time updates before freezing
|
||||||
|
db.enableRealtimeUpdates({ interval: 100 })
|
||||||
|
|
||||||
|
// Freeze the database
|
||||||
|
db.setFrozen(true)
|
||||||
|
|
||||||
|
// Real-time updates should be stopped
|
||||||
|
const config = db.getRealtimeUpdateConfig()
|
||||||
|
// The config might still say enabled, but updates won't run
|
||||||
|
|
||||||
|
// Statistics flush should be a no-op (not throw, just do nothing)
|
||||||
|
await db.flushStatistics() // Should not throw but does nothing
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
db.setFrozen(false)
|
||||||
|
db.disableRealtimeUpdates()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restart real-time updates when unfrozen', async () => {
|
||||||
|
// Enable real-time updates
|
||||||
|
db.enableRealtimeUpdates({ interval: 100 })
|
||||||
|
const configBefore = db.getRealtimeUpdateConfig()
|
||||||
|
expect(configBefore.enabled).toBe(true)
|
||||||
|
|
||||||
|
// Freeze the database
|
||||||
|
db.setFrozen(true)
|
||||||
|
|
||||||
|
// Unfreeze the database
|
||||||
|
db.setFrozen(false)
|
||||||
|
|
||||||
|
// Real-time updates should restart
|
||||||
|
const configAfter = db.getRealtimeUpdateConfig()
|
||||||
|
expect(configAfter.enabled).toBe(true)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
db.disableRealtimeUpdates()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readOnly with frozen', () => {
|
||||||
|
it('should enforce complete immutability', async () => {
|
||||||
|
// Set both readOnly and frozen
|
||||||
|
db.setReadOnly(true)
|
||||||
|
db.setFrozen(true)
|
||||||
|
|
||||||
|
expect(db.isReadOnly()).toBe(true)
|
||||||
|
expect(db.isFrozen()).toBe(true)
|
||||||
|
|
||||||
|
// Data mutations should fail
|
||||||
|
await expect(db.add('test item 3', { content: 'Third item' })).rejects.toThrow('read-only mode')
|
||||||
|
|
||||||
|
// Statistics flush should be a no-op
|
||||||
|
await db.flushStatistics() // Should not throw but does nothing
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
db.setReadOnly(false)
|
||||||
|
db.setFrozen(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('configuration via constructor', () => {
|
||||||
|
it('should respect frozen flag from constructor', async () => {
|
||||||
|
const frozenDb = new BrainyData({
|
||||||
|
storageAdapter: new MemoryStorage(),
|
||||||
|
readOnly: true,
|
||||||
|
frozen: true
|
||||||
|
})
|
||||||
|
await frozenDb.init()
|
||||||
|
|
||||||
|
expect(frozenDb.isReadOnly()).toBe(true)
|
||||||
|
expect(frozenDb.isFrozen()).toBe(true)
|
||||||
|
|
||||||
|
await frozenDb.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default frozen to false when readOnly is true', async () => {
|
||||||
|
const readOnlyDb = new BrainyData({
|
||||||
|
storageAdapter: new MemoryStorage(),
|
||||||
|
readOnly: true
|
||||||
|
// frozen not specified, should default to false
|
||||||
|
})
|
||||||
|
await readOnlyDb.init()
|
||||||
|
|
||||||
|
expect(readOnlyDb.isReadOnly()).toBe(true)
|
||||||
|
expect(readOnlyDb.isFrozen()).toBe(false)
|
||||||
|
|
||||||
|
await readOnlyDb.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
89
tests/high-volume-test.ts
Normal file
89
tests/high-volume-test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* High-volume test to verify automatic adaptation under load
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../src/index.js'
|
||||||
|
import { getGlobalPerformanceMonitor } from '../src/utils/performanceMonitor.js'
|
||||||
|
|
||||||
|
async function testHighVolume() {
|
||||||
|
console.log('Starting high-volume test with automatic adaptation...')
|
||||||
|
|
||||||
|
// Create a Brainy instance with S3 storage (configure as needed)
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
dimensions: 384,
|
||||||
|
storage: { type: 'memory' } // Use memory for testing
|
||||||
|
})
|
||||||
|
|
||||||
|
const monitor = getGlobalPerformanceMonitor()
|
||||||
|
|
||||||
|
// Generate test data
|
||||||
|
const numItems = 1000
|
||||||
|
const batchSize = 50
|
||||||
|
|
||||||
|
console.log(`Testing with ${numItems} items in batches of ${batchSize}`)
|
||||||
|
|
||||||
|
// Add items in batches
|
||||||
|
for (let i = 0; i < numItems; i += batchSize) {
|
||||||
|
const batch = []
|
||||||
|
for (let j = 0; j < batchSize && i + j < numItems; j++) {
|
||||||
|
const vector = new Array(384).fill(0).map(() => Math.random())
|
||||||
|
batch.push({
|
||||||
|
key: `item-${i + j}`,
|
||||||
|
data: {
|
||||||
|
content: `Test item ${i + j}`,
|
||||||
|
timestamp: Date.now()
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
batch: Math.floor(i / batchSize),
|
||||||
|
index: i + j
|
||||||
|
},
|
||||||
|
vector
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add batch
|
||||||
|
const startTime = Date.now()
|
||||||
|
try {
|
||||||
|
await brainy.addBatch(batch)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// Track performance
|
||||||
|
monitor.trackOperation(true, latency, JSON.stringify(batch).length)
|
||||||
|
|
||||||
|
if (i % 200 === 0) {
|
||||||
|
const report = monitor.getReport()
|
||||||
|
console.log(`Progress: ${i}/${numItems}`)
|
||||||
|
console.log(` Health Score: ${report.metrics.healthScore.toFixed(0)}`)
|
||||||
|
console.log(` Ops/sec: ${report.metrics.operationsPerSecond.toFixed(1)}`)
|
||||||
|
console.log(` Avg Latency: ${report.metrics.averageLatency.toFixed(0)}ms`)
|
||||||
|
console.log(` Socket Config:`, report.socketConfig)
|
||||||
|
console.log(` Backpressure:`, report.backpressureStatus)
|
||||||
|
|
||||||
|
if (report.recommendations.length > 0) {
|
||||||
|
console.log(` Recommendations:`, report.recommendations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
monitor.trackOperation(false, latency, 0)
|
||||||
|
console.error(`Failed to add batch at ${i}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final report
|
||||||
|
const finalReport = monitor.getReport()
|
||||||
|
console.log('\n=== Final Performance Report ===')
|
||||||
|
console.log('Metrics:', finalReport.metrics)
|
||||||
|
console.log('Trends:', finalReport.trends)
|
||||||
|
console.log('Socket Configuration:', finalReport.socketConfig)
|
||||||
|
console.log('Backpressure Status:', finalReport.backpressureStatus)
|
||||||
|
|
||||||
|
if (finalReport.recommendations.length > 0) {
|
||||||
|
console.log('Recommendations:', finalReport.recommendations)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nTest completed successfully!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testHighVolume().catch(console.error)
|
||||||
507
tests/intelligent-verb-scoring.test.ts
Normal file
507
tests/intelligent-verb-scoring.test.ts
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { IntelligentVerbScoring } from '../src/augmentations/intelligentVerbScoring.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a test vector
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 384] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Intelligent Verb Scoring', () => {
|
||||||
|
let db: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Initialize with intelligent verb scoring enabled
|
||||||
|
db = new BrainyData({
|
||||||
|
intelligentVerbScoring: {
|
||||||
|
enabled: true,
|
||||||
|
enableSemanticScoring: true,
|
||||||
|
enableFrequencyAmplification: true,
|
||||||
|
enableTemporalDecay: true,
|
||||||
|
baseConfidence: 0.5,
|
||||||
|
learningRate: 0.1
|
||||||
|
},
|
||||||
|
logging: { verbose: false } // Reduce noise in tests
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.cleanup?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Configuration and Initialization', () => {
|
||||||
|
it('should be disabled by default', async () => {
|
||||||
|
const defaultDb = new BrainyData()
|
||||||
|
await defaultDb.init()
|
||||||
|
|
||||||
|
// Add entities first using vectors
|
||||||
|
await defaultDb.add(createTestVector(0), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await defaultDb.add(createTestVector(1), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Add a verb - should not trigger intelligent scoring
|
||||||
|
const verbId = await defaultDb.addVerb('entity1', 'entity2', undefined, { type: 'relatesTo' })
|
||||||
|
|
||||||
|
const verb = await defaultDb.getVerb(verbId)
|
||||||
|
expect(verb?.metadata?.intelligentScoring).toBeUndefined()
|
||||||
|
|
||||||
|
await defaultDb.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with custom configuration', async () => {
|
||||||
|
const customDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: {
|
||||||
|
enabled: true,
|
||||||
|
baseConfidence: 0.8,
|
||||||
|
minWeight: 0.2,
|
||||||
|
maxWeight: 0.9,
|
||||||
|
learningRate: 0.2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await customDb.init()
|
||||||
|
|
||||||
|
// Add entities first using vectors
|
||||||
|
await customDb.add(createTestVector(0), { id: 'entity1', data: 'Software developer' })
|
||||||
|
await customDb.add(createTestVector(1), { id: 'entity2', data: 'Web application' })
|
||||||
|
const verbId = await customDb.addVerb('entity1', 'entity2', undefined, { type: 'develops' })
|
||||||
|
|
||||||
|
const verb = await customDb.getVerb(verbId)
|
||||||
|
|
||||||
|
// Check that intelligent scoring system is working via stats
|
||||||
|
const scoringStats = customDb.getVerbScoringStats()
|
||||||
|
expect(scoringStats).toBeTruthy()
|
||||||
|
expect(scoringStats.totalRelationships).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Note: Due to current implementation limitations with verb metadata persistence,
|
||||||
|
// we verify scoring is working through the scoring stats rather than verb metadata
|
||||||
|
expect(verb).toBeTruthy()
|
||||||
|
expect(verb?.id).toBe(verbId)
|
||||||
|
|
||||||
|
await customDb.cleanup?.()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Semantic Scoring', () => {
|
||||||
|
it('should compute semantic similarity between entities', async () => {
|
||||||
|
// Add semantically similar entities (using vectors with small differences)
|
||||||
|
await db.add(createTestVector(0), { id: 'developer1', data: 'John is a software developer who writes JavaScript' })
|
||||||
|
await db.add(createTestVector(1), { id: 'developer2', data: 'Jane is a programmer who codes in TypeScript' })
|
||||||
|
|
||||||
|
// Add semantically different entities (using vectors with larger differences)
|
||||||
|
await db.add(createTestVector(100), { id: 'restaurant1', data: 'Italian restaurant serving pasta' })
|
||||||
|
await db.add(createTestVector(200), { id: 'car1', data: 'Red sports car with V8 engine' })
|
||||||
|
|
||||||
|
// Test similar entities
|
||||||
|
const similarVerbId = await db.addVerb('developer1', 'developer2', undefined, { type: 'collaboratesWith',
|
||||||
|
autoCreateMissingNouns: true
|
||||||
|
})
|
||||||
|
const similarVerb = await db.getVerb(similarVerbId)
|
||||||
|
|
||||||
|
// Test different entities
|
||||||
|
const differentVerbId = await db.addVerb('developer1', 'restaurant1', undefined, { type: 'relatesTo',
|
||||||
|
autoCreateMissingNouns: true
|
||||||
|
})
|
||||||
|
const differentVerb = await db.getVerb(differentVerbId)
|
||||||
|
|
||||||
|
// Both verbs should have computed weights (not default 0.5)
|
||||||
|
expect(similarVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(differentVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(similarVerb.metadata.weight).not.toBe(0.5)
|
||||||
|
expect(differentVerb.metadata.weight).not.toBe(0.5)
|
||||||
|
|
||||||
|
// Test passes if both weights are computed differently or if semantic scoring is working
|
||||||
|
const weightDifference = Math.abs(similarVerb.metadata.weight - differentVerb.metadata.weight)
|
||||||
|
expect(weightDifference).toBeGreaterThanOrEqual(0) // At minimum, they should be computed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not affect explicitly provided weights', async () => {
|
||||||
|
await db.add(createTestVector(10), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await db.add(createTestVector(11), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
const explicitWeight = 0.75
|
||||||
|
const verbId = await db.addVerb('entity1', 'entity2', undefined, { type: 'hasRelation',
|
||||||
|
weight: explicitWeight
|
||||||
|
})
|
||||||
|
|
||||||
|
const verb = await db.getVerb(verbId)
|
||||||
|
expect(verb.metadata.weight).toBe(explicitWeight)
|
||||||
|
expect(verb.metadata.intelligentScoring).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Frequency Amplification', () => {
|
||||||
|
it('should increase weight for repeated relationships', async () => {
|
||||||
|
await db.add(createTestVector(20), { id: 'user1', data: 'Software engineer' })
|
||||||
|
await db.add(createTestVector(21), { id: 'project1', data: 'Web development project' })
|
||||||
|
|
||||||
|
// Add the same relationship multiple times
|
||||||
|
const firstVerbId = await db.addVerb('user1', 'project1', undefined, { type: 'worksOn', autoCreateMissingNouns: true })
|
||||||
|
const firstVerb = await db.getVerb(firstVerbId)
|
||||||
|
const firstWeight = firstVerb.metadata.weight
|
||||||
|
|
||||||
|
// Add the relationship again (simulating repeated occurrence)
|
||||||
|
const secondVerbId = await db.addVerb('user1', 'project1', undefined, { type: 'worksOn', autoCreateMissingNouns: true })
|
||||||
|
const secondVerb = await db.getVerb(secondVerbId)
|
||||||
|
const secondWeight = secondVerb.metadata.weight
|
||||||
|
|
||||||
|
// Third time
|
||||||
|
const thirdVerbId = await db.addVerb('user1', 'project1', undefined, { type: 'worksOn', autoCreateMissingNouns: true })
|
||||||
|
const thirdVerb = await db.getVerb(thirdVerbId)
|
||||||
|
const thirdWeight = thirdVerb.metadata.weight
|
||||||
|
|
||||||
|
// Weight should vary with frequency (due to learning from patterns)
|
||||||
|
// The system may adjust weights based on patterns, so we test that weights are computed
|
||||||
|
expect(firstWeight).toBeDefined()
|
||||||
|
expect(secondWeight).toBeDefined()
|
||||||
|
expect(thirdWeight).toBeDefined()
|
||||||
|
expect(typeof firstWeight).toBe('number')
|
||||||
|
expect(typeof secondWeight).toBe('number')
|
||||||
|
expect(typeof thirdWeight).toBe('number')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Learning and Feedback', () => {
|
||||||
|
it('should accept and learn from feedback', async () => {
|
||||||
|
await db.add(createTestVector(30), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await db.add(createTestVector(31), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Add initial relationship
|
||||||
|
await db.addVerb('entity1', 'entity2', undefined, { type: 'testRelation', autoCreateMissingNouns: true })
|
||||||
|
|
||||||
|
// Provide feedback
|
||||||
|
await db.provideFeedbackForVerbScoring(
|
||||||
|
'entity1', 'entity2', 'testRelation',
|
||||||
|
0.9, // high weight feedback
|
||||||
|
0.85, // high confidence feedback
|
||||||
|
'correction'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add the same type of relationship again
|
||||||
|
await db.add(createTestVector(32), { id: 'entity3', data: 'Test entity 3' })
|
||||||
|
await db.add(createTestVector(33), { id: 'entity4', data: 'Test entity 4' })
|
||||||
|
const newVerbId = await db.addVerb('entity3', 'entity4', undefined, { type: 'testRelation', autoCreateMissingNouns: true })
|
||||||
|
|
||||||
|
const newVerb = await db.getVerb(newVerbId)
|
||||||
|
|
||||||
|
// New relationship should have a computed weight (feedback system working)
|
||||||
|
expect(newVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(typeof newVerb.metadata.weight).toBe('number')
|
||||||
|
expect(newVerb.metadata.weight).toBeGreaterThan(0) // Should have a positive weight
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide learning statistics', async () => {
|
||||||
|
await db.add(createTestVector(40), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await db.add(createTestVector(41), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Add some relationships
|
||||||
|
await db.addVerb('entity1', 'entity2', undefined, { type: 'relation1', autoCreateMissingNouns: true })
|
||||||
|
await db.addVerb('entity2', 'entity1', undefined, { type: 'relation2', autoCreateMissingNouns: true })
|
||||||
|
|
||||||
|
// Provide feedback
|
||||||
|
await db.provideFeedbackForVerbScoring('entity1', 'entity2', 'relation1', 0.8)
|
||||||
|
|
||||||
|
const stats = db.getVerbScoringStats()
|
||||||
|
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats.totalRelationships).toBeGreaterThan(0)
|
||||||
|
expect(stats.feedbackCount).toBeGreaterThan(0)
|
||||||
|
expect(Array.isArray(stats.topRelationships)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export and import learning data', async () => {
|
||||||
|
await db.add(createTestVector(50), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await db.add(createTestVector(51), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Create some learning data
|
||||||
|
await db.addVerb('entity1', 'entity2', undefined, { type: 'testRelation', autoCreateMissingNouns: true })
|
||||||
|
await db.provideFeedbackForVerbScoring('entity1', 'entity2', 'testRelation', 0.9)
|
||||||
|
|
||||||
|
// Export learning data
|
||||||
|
const exportedData = db.exportVerbScoringLearningData()
|
||||||
|
expect(exportedData).toBeTruthy()
|
||||||
|
expect(typeof exportedData).toBe('string')
|
||||||
|
|
||||||
|
// Parse to verify it's valid JSON
|
||||||
|
const parsed = JSON.parse(exportedData!)
|
||||||
|
expect(parsed.version).toBe('1.0')
|
||||||
|
expect(Array.isArray(parsed.stats)).toBe(true)
|
||||||
|
|
||||||
|
// Create new instance and import
|
||||||
|
const newDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: { enabled: true }
|
||||||
|
})
|
||||||
|
await newDb.init()
|
||||||
|
|
||||||
|
newDb.importVerbScoringLearningData(exportedData!)
|
||||||
|
|
||||||
|
const importedStats = newDb.getVerbScoringStats()
|
||||||
|
expect(importedStats?.totalRelationships).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await newDb.cleanup?.()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Temporal Decay', () => {
|
||||||
|
it('should apply temporal decay configuration', async () => {
|
||||||
|
// Test temporal decay is applied by checking configuration is used
|
||||||
|
const temporalDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: {
|
||||||
|
enabled: true,
|
||||||
|
enableTemporalDecay: true,
|
||||||
|
temporalDecayRate: 0.1 // High decay rate for testing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await temporalDb.init()
|
||||||
|
await temporalDb.add(createTestVector(60), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await temporalDb.add(createTestVector(61), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
const verbId = await temporalDb.addVerb('entity1', 'entity2', undefined, { type: 'decayingRelation', autoCreateMissingNouns: true })
|
||||||
|
const verb = await temporalDb.getVerb(verbId)
|
||||||
|
|
||||||
|
// Verify temporal decay is working by checking computed weight
|
||||||
|
expect(verb.metadata.weight).toBeDefined()
|
||||||
|
expect(typeof verb.metadata.weight).toBe('number')
|
||||||
|
|
||||||
|
// If intelligentScoring is available, check for temporal reasoning
|
||||||
|
if (verb.metadata.intelligentScoring) {
|
||||||
|
expect(verb.metadata.intelligentScoring.reasoning).toBeInstanceOf(Array)
|
||||||
|
const reasoningText = verb.metadata.intelligentScoring.reasoning.join(' ')
|
||||||
|
expect(reasoningText).toMatch(/temporal|decay|time/i)
|
||||||
|
}
|
||||||
|
|
||||||
|
await temporalDb.cleanup?.()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Weight and Confidence Bounds', () => {
|
||||||
|
it('should respect configured weight bounds', async () => {
|
||||||
|
const boundedDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: {
|
||||||
|
enabled: true,
|
||||||
|
minWeight: 0.3,
|
||||||
|
maxWeight: 0.8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await boundedDb.init()
|
||||||
|
await boundedDb.add(createTestVector(70), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await boundedDb.add(createTestVector(71), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Add multiple relationships to test bounds
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await boundedDb.add(createTestVector(72 + i), { id: `entity${i+3}`, data: `Test entity ${i+3}` })
|
||||||
|
const verbId = await boundedDb.addVerb('entity1', `entity${i+3}`, undefined, { type: 'testRelation', autoCreateMissingNouns: true })
|
||||||
|
const verb = await boundedDb.getVerb(verbId)
|
||||||
|
|
||||||
|
expect(verb.metadata.weight).toBeGreaterThanOrEqual(0.3)
|
||||||
|
expect(verb.metadata.weight).toBeLessThanOrEqual(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
await boundedDb.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide reasoning information', async () => {
|
||||||
|
await db.add(createTestVector(80), { id: 'entity1', data: 'Software developer with expertise in JavaScript' })
|
||||||
|
await db.add(createTestVector(81), { id: 'entity2', data: 'React application for web development' })
|
||||||
|
|
||||||
|
const verbId = await db.addVerb('entity1', 'entity2', undefined, { type: 'develops', autoCreateMissingNouns: true })
|
||||||
|
const verb = await db.getVerb(verbId)
|
||||||
|
|
||||||
|
// Verify that intelligent verb scoring is working by checking computed properties
|
||||||
|
expect(verb.metadata.weight).toBeDefined()
|
||||||
|
expect(typeof verb.metadata.weight).toBe('number')
|
||||||
|
expect(verb.metadata.weight).not.toBe(0.5) // Should be computed, not default
|
||||||
|
|
||||||
|
// If intelligentScoring is available, it should have the right structure
|
||||||
|
if (verb.metadata.intelligentScoring) {
|
||||||
|
expect(verb.metadata.intelligentScoring.reasoning).toBeInstanceOf(Array)
|
||||||
|
expect(verb.metadata.intelligentScoring.reasoning.length).toBeGreaterThan(0)
|
||||||
|
expect(verb.metadata.intelligentScoring.computedAt).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should gracefully handle errors in scoring computation', async () => {
|
||||||
|
// Create a scenario that might cause errors (missing entities, etc.)
|
||||||
|
const errorDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: { enabled: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await errorDb.init()
|
||||||
|
|
||||||
|
// Try to add verb with potentially problematic data
|
||||||
|
await errorDb.add(createTestVector(90), { id: 'entity1', data: null }) // null metadata might cause issues
|
||||||
|
await errorDb.add(createTestVector(91), { id: 'entity2', data: '' }) // empty metadata
|
||||||
|
|
||||||
|
// Should not throw error, should fall back gracefully
|
||||||
|
const verbId = await errorDb.addVerb('entity1', 'entity2', undefined, { type: 'testRelation', autoCreateMissingNouns: true })
|
||||||
|
const verb = await errorDb.getVerb(verbId)
|
||||||
|
|
||||||
|
expect(verbId).toBeTruthy()
|
||||||
|
expect(verb.metadata.weight).toBeDefined()
|
||||||
|
|
||||||
|
await errorDb.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle disabled state gracefully', async () => {
|
||||||
|
const disabledDb = new BrainyData({
|
||||||
|
intelligentVerbScoring: {
|
||||||
|
enabled: false // Explicitly disabled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await disabledDb.init()
|
||||||
|
|
||||||
|
// These should not throw errors even though scoring is disabled
|
||||||
|
await disabledDb.provideFeedbackForVerbScoring('a', 'b', 'rel', 0.8)
|
||||||
|
expect(disabledDb.getVerbScoringStats()).toBeNull()
|
||||||
|
expect(disabledDb.exportVerbScoringLearningData()).toBeNull()
|
||||||
|
|
||||||
|
await disabledDb.cleanup?.()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration with Existing Verbs', () => {
|
||||||
|
it('should only score verbs without explicit weights', async () => {
|
||||||
|
await db.add(createTestVector(100), { id: 'entity1', data: 'Test entity 1' })
|
||||||
|
await db.add(createTestVector(101), { id: 'entity2', data: 'Test entity 2' })
|
||||||
|
|
||||||
|
// Add verb with explicit weight
|
||||||
|
const explicitVerbId = await db.addVerb('entity1', 'entity2', undefined, { type: 'explicitRel',
|
||||||
|
weight: 0.6,
|
||||||
|
autoCreateMissingNouns: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add verb without weight
|
||||||
|
const smartVerbId = await db.addVerb('entity1', 'entity2', undefined, { type: 'smartRel', autoCreateMissingNouns: true })
|
||||||
|
|
||||||
|
const explicitVerb = await db.getVerb(explicitVerbId)
|
||||||
|
const smartVerb = await db.getVerb(smartVerbId)
|
||||||
|
|
||||||
|
// Explicit weight should be preserved
|
||||||
|
expect(explicitVerb.metadata.weight).toBe(0.6)
|
||||||
|
expect(explicitVerb.metadata.intelligentScoring).toBeUndefined()
|
||||||
|
|
||||||
|
// Smart verb should have computed weight (not default)
|
||||||
|
expect(smartVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(typeof smartVerb.metadata.weight).toBe('number')
|
||||||
|
expect(smartVerb.metadata.weight).not.toBe(0.5) // Should be computed, not default
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work with different verb types', async () => {
|
||||||
|
await db.add(createTestVector(110), { id: 'person1', data: 'Software engineer' })
|
||||||
|
await db.add(createTestVector(111), { id: 'project1', data: 'Web application' })
|
||||||
|
await db.add(createTestVector(112), { id: 'company1', data: 'Technology startup' })
|
||||||
|
|
||||||
|
// Test different relationship types
|
||||||
|
const workVerbId = await db.addVerb('person1', 'project1', undefined, { type: 'worksOn', autoCreateMissingNouns: true })
|
||||||
|
const employVerbId = await db.addVerb('company1', 'person1', undefined, { type: 'employs', autoCreateMissingNouns: true })
|
||||||
|
const ownVerbId = await db.addVerb('company1', 'project1', undefined, { type: 'owns', autoCreateMissingNouns: true })
|
||||||
|
|
||||||
|
const workVerb = await db.getVerb(workVerbId)
|
||||||
|
const employVerb = await db.getVerb(employVerbId)
|
||||||
|
const ownVerb = await db.getVerb(ownVerbId)
|
||||||
|
|
||||||
|
// All should have computed weights from intelligent scoring
|
||||||
|
expect(workVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(employVerb.metadata.weight).toBeDefined()
|
||||||
|
expect(ownVerb.metadata.weight).toBeDefined()
|
||||||
|
|
||||||
|
// Weights should be computed (not default) and positive
|
||||||
|
expect(typeof workVerb.metadata.weight).toBe('number')
|
||||||
|
expect(typeof employVerb.metadata.weight).toBe('number')
|
||||||
|
expect(typeof ownVerb.metadata.weight).toBe('number')
|
||||||
|
expect(workVerb.metadata.weight).toBeGreaterThan(0)
|
||||||
|
expect(employVerb.metadata.weight).toBeGreaterThan(0)
|
||||||
|
expect(ownVerb.metadata.weight).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Considerations', () => {
|
||||||
|
it('should not significantly impact verb creation performance', async () => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// Add many entities and relationships
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await db.add(createTestVector(120 + i), { id: `entity${i}`, data: `Test entity number ${i}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await db.addVerb(`entity${i}`, `entity${(i + 1) % 50}`, undefined, { type: 'connectsTo', autoCreateMissingNouns: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
const duration = endTime - startTime
|
||||||
|
|
||||||
|
// Should complete reasonably quickly (adjust threshold as needed)
|
||||||
|
expect(duration).toBeLessThan(10000) // 10 seconds max for 50 relationships
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Standalone IntelligentVerbScoring class', () => {
|
||||||
|
it('should work as standalone augmentation', async () => {
|
||||||
|
const scoring = new IntelligentVerbScoring({
|
||||||
|
enableSemanticScoring: true,
|
||||||
|
baseConfidence: 0.6
|
||||||
|
})
|
||||||
|
|
||||||
|
scoring.enabled = true
|
||||||
|
await scoring.initialize()
|
||||||
|
|
||||||
|
expect(await scoring.getStatus()).toBe('active')
|
||||||
|
|
||||||
|
// Test interface methods
|
||||||
|
const reasonResult = scoring.reason('test query')
|
||||||
|
expect(reasonResult.success).toBe(true)
|
||||||
|
|
||||||
|
const inferResult = scoring.infer({ test: 'data' })
|
||||||
|
expect(inferResult.success).toBe(true)
|
||||||
|
|
||||||
|
const logicResult = scoring.executeLogic('rule1', { input: 'test' })
|
||||||
|
expect(logicResult.success).toBe(true)
|
||||||
|
|
||||||
|
await scoring.shutDown()
|
||||||
|
expect(await scoring.getStatus()).toBe('inactive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should manage relationship statistics', async () => {
|
||||||
|
const scoring = new IntelligentVerbScoring()
|
||||||
|
scoring.enabled = true
|
||||||
|
await scoring.initialize()
|
||||||
|
|
||||||
|
// Manually add relationship stats (simulating usage)
|
||||||
|
await scoring.provideFeedback('a', 'b', 'rel', 0.8, 0.75, 'validation')
|
||||||
|
await scoring.provideFeedback('c', 'd', 'rel', 0.6, 0.65, 'correction')
|
||||||
|
|
||||||
|
const stats = scoring.getRelationshipStats()
|
||||||
|
expect(stats.size).toBe(2)
|
||||||
|
|
||||||
|
const learningStats = scoring.getLearningStats()
|
||||||
|
expect(learningStats.totalRelationships).toBe(2)
|
||||||
|
expect(learningStats.feedbackCount).toBe(2)
|
||||||
|
|
||||||
|
// Test export/import
|
||||||
|
const exported = scoring.exportLearningData()
|
||||||
|
expect(exported).toBeTruthy()
|
||||||
|
|
||||||
|
scoring.clearStats()
|
||||||
|
expect(scoring.getRelationshipStats().size).toBe(0)
|
||||||
|
|
||||||
|
scoring.importLearningData(exported)
|
||||||
|
expect(scoring.getRelationshipStats().size).toBe(2)
|
||||||
|
|
||||||
|
await scoring.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
120
tests/json-search-test.js
Normal file
120
tests/json-search-test.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Test script to demonstrate improved JSON document search capabilities
|
||||||
|
*
|
||||||
|
* This script tests the new functionality for searching within JSON documents,
|
||||||
|
* particularly focusing on company names in nested fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
console.log('Starting JSON document search test...')
|
||||||
|
|
||||||
|
// Initialize BrainyData
|
||||||
|
const brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('Adding test documents...')
|
||||||
|
|
||||||
|
// Add some test documents with company names in different fields
|
||||||
|
const doc1Id = await brainy.add({
|
||||||
|
title: 'Employee Profile',
|
||||||
|
person: {
|
||||||
|
name: 'John Smith',
|
||||||
|
company: 'Acme Corporation',
|
||||||
|
position: 'Software Engineer'
|
||||||
|
},
|
||||||
|
skills: ['JavaScript', 'TypeScript', 'React']
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc2Id = await brainy.add({
|
||||||
|
title: 'Project Proposal',
|
||||||
|
client: {
|
||||||
|
name: 'TechSolutions Inc.',
|
||||||
|
industry: 'Software Development'
|
||||||
|
},
|
||||||
|
description: 'A project to develop a new CRM system'
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc3Id = await brainy.add({
|
||||||
|
title: 'Partnership Agreement',
|
||||||
|
parties: [
|
||||||
|
{
|
||||||
|
name: 'Global Innovations Ltd',
|
||||||
|
type: 'Service Provider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DataCorp',
|
||||||
|
type: 'Client'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
details: 'Agreement for providing data processing services'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Documents added with IDs:', doc1Id, doc2Id, doc3Id)
|
||||||
|
|
||||||
|
// Test 1: Standard search (before our improvements, this might not work well)
|
||||||
|
console.log('\nTest 1: Standard search for "Acme Corporation"')
|
||||||
|
const results1 = await brainy.search('Acme Corporation', 5)
|
||||||
|
console.log(`Found ${results1.length} results:`)
|
||||||
|
results1.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 2: Search with our new JSON processing (should work better)
|
||||||
|
console.log('\nTest 2: Search with JSON object and priority fields')
|
||||||
|
const results2 = await brainy.search({ company: 'Acme Corporation' }, 5, {
|
||||||
|
priorityFields: ['company', 'name']
|
||||||
|
})
|
||||||
|
console.log(`Found ${results2.length} results:`)
|
||||||
|
results2.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 3: Field-specific search
|
||||||
|
console.log('\nTest 3: Field-specific search for "person.company=Acme Corporation"')
|
||||||
|
const results3 = await brainy.search({ searchTerm: 'Acme Corporation' }, 5, {
|
||||||
|
searchField: 'person.company'
|
||||||
|
})
|
||||||
|
console.log(`Found ${results3.length} results:`)
|
||||||
|
results3.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 4: Search for TechSolutions Inc.
|
||||||
|
console.log('\nTest 4: Search for "TechSolutions Inc."')
|
||||||
|
const results4 = await brainy.search('TechSolutions Inc.', 5)
|
||||||
|
console.log(`Found ${results4.length} results:`)
|
||||||
|
results4.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 5: Field-specific search for TechSolutions
|
||||||
|
console.log('\nTest 5: Field-specific search for "client.name=TechSolutions Inc."')
|
||||||
|
const results5 = await brainy.search({ searchTerm: 'TechSolutions Inc.' }, 5, {
|
||||||
|
searchField: 'client.name'
|
||||||
|
})
|
||||||
|
console.log(`Found ${results5.length} results:`)
|
||||||
|
results5.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test 6: Search for DataCorp in nested array
|
||||||
|
console.log('\nTest 6: Search for "DataCorp" in nested array')
|
||||||
|
const results6 = await brainy.search('DataCorp', 5)
|
||||||
|
console.log(`Found ${results6.length} results:`)
|
||||||
|
results6.forEach((result, i) => {
|
||||||
|
console.log(`Result ${i+1}: ID=${result.id}, Score=${result.score}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await brainy.clear()
|
||||||
|
console.log('\nTest completed and database cleared.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
runTest().catch(error => {
|
||||||
|
console.error('Test failed:', error)
|
||||||
|
})
|
||||||
101
tests/metadata-filter-debug.test.ts
Normal file
101
tests/metadata-filter-debug.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
|
||||||
|
describe('Metadata Filter Works', () => {
|
||||||
|
it('should filter results by metadata during search', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 4, efConstruction: 20 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
await brainy.add('Senior developer Alice works with React', { level: 'senior', skill: 'React' })
|
||||||
|
await brainy.add('Junior developer Bob learns Vue', { level: 'junior', skill: 'Vue' })
|
||||||
|
await brainy.add('Senior developer Charlie codes Python', { level: 'senior', skill: 'Python' })
|
||||||
|
|
||||||
|
// Test 1: Search without filter (should return all 3)
|
||||||
|
const allResults = await brainy.searchText('developer', 10)
|
||||||
|
expect(allResults.length).toBe(3)
|
||||||
|
|
||||||
|
// Test 2: Search with level filter (should return 2 senior developers)
|
||||||
|
const seniorResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
expect(seniorResults.length).toBe(2)
|
||||||
|
expect(seniorResults.every(r => r.metadata?.level === 'senior')).toBe(true)
|
||||||
|
|
||||||
|
// Test 3: Search with skill filter (should return 1 React developer)
|
||||||
|
const reactResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { skill: 'React' }
|
||||||
|
})
|
||||||
|
expect(reactResults.length).toBe(1)
|
||||||
|
expect(reactResults[0].metadata?.skill).toBe('React')
|
||||||
|
|
||||||
|
// Test 4: Search with multiple filters (should return 1 senior React developer)
|
||||||
|
const seniorReactResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
level: 'senior',
|
||||||
|
skill: 'React'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(seniorReactResults.length).toBe(1)
|
||||||
|
expect(seniorReactResults[0].metadata?.level).toBe('senior')
|
||||||
|
expect(seniorReactResults[0].metadata?.skill).toBe('React')
|
||||||
|
|
||||||
|
// Test 5: Search with no matches (should return 0)
|
||||||
|
const noResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { level: 'expert' }
|
||||||
|
})
|
||||||
|
expect(noResults.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work with searchWithinItems', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 4, efConstruction: 20 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
const id1 = await brainy.add('Frontend React developer', { type: 'frontend', skill: 'React' })
|
||||||
|
const id2 = await brainy.add('Backend Node developer', { type: 'backend', skill: 'Node' })
|
||||||
|
const id3 = await brainy.add('Frontend Vue developer', { type: 'frontend', skill: 'Vue' })
|
||||||
|
|
||||||
|
// Search within only frontend developers
|
||||||
|
const frontendResults = await brainy.searchWithinItems('developer', [id1, id3], 10)
|
||||||
|
|
||||||
|
expect(frontendResults.length).toBe(2)
|
||||||
|
expect(frontendResults.every(r => [id1, id3].includes(r.id))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle MongoDB-style operators', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 4, efConstruction: 20 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
await brainy.add('Developer with 5 years experience', { experience: 5, skills: ['React', 'Node'] })
|
||||||
|
await brainy.add('Developer with 2 years experience', { experience: 2, skills: ['Vue'] })
|
||||||
|
await brainy.add('Developer with 8 years experience', { experience: 8, skills: ['React', 'Python'] })
|
||||||
|
|
||||||
|
// Test $gt operator
|
||||||
|
const experiencedResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { experience: { $gt: 3 } }
|
||||||
|
})
|
||||||
|
expect(experiencedResults.length).toBe(2)
|
||||||
|
expect(experiencedResults.every(r => (r.metadata?.experience as number) > 3)).toBe(true)
|
||||||
|
|
||||||
|
// Test $in operator
|
||||||
|
const skillResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { experience: { $in: [2, 8] } }
|
||||||
|
})
|
||||||
|
expect(skillResults.length).toBe(2)
|
||||||
|
expect(skillResults.every(r => [2, 8].includes(r.metadata?.experience as number))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
272
tests/metadata-filter-environments.test.ts
Normal file
272
tests/metadata-filter-environments.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { isNode, isBrowser } from '../src/utils/environment.js'
|
||||||
|
|
||||||
|
describe('Metadata Filtering - Cross-Environment', () => {
|
||||||
|
const testConfigurations = [
|
||||||
|
{
|
||||||
|
name: 'Memory Storage',
|
||||||
|
config: { storage: { forceMemoryStorage: true } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add Node.js specific storage adapters
|
||||||
|
if (isNode()) {
|
||||||
|
testConfigurations.push({
|
||||||
|
name: 'FileSystem Storage',
|
||||||
|
config: { storage: { forceFileSystemStorage: true } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add browser specific storage adapters
|
||||||
|
if (isBrowser()) {
|
||||||
|
testConfigurations.push({
|
||||||
|
name: 'OPFS Storage',
|
||||||
|
config: { storage: { requestPersistentStorage: false } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test each storage configuration
|
||||||
|
for (const testConfig of testConfigurations) {
|
||||||
|
describe(`${testConfig.name}`, () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
...testConfig.config,
|
||||||
|
hnsw: { M: 8, efConstruction: 50 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
const testData = [
|
||||||
|
{
|
||||||
|
content: 'Senior React developer in San Francisco',
|
||||||
|
metadata: { level: 'senior', skill: 'React', location: 'SF', remote: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Junior Vue developer in New York',
|
||||||
|
metadata: { level: 'junior', skill: 'Vue', location: 'NYC', remote: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Mid-level TypeScript developer remote',
|
||||||
|
metadata: { level: 'mid', skill: 'TypeScript', location: 'Remote', remote: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Senior Python engineer in San Francisco',
|
||||||
|
metadata: { level: 'senior', skill: 'Python', location: 'SF', remote: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Senior JavaScript developer in Austin',
|
||||||
|
metadata: { level: 'senior', skill: 'JavaScript', location: 'Austin', remote: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.content, item.metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter by exact metadata match', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r => r.metadata?.level === 'senior')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter by multiple fields', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
level: 'senior',
|
||||||
|
location: 'SF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r =>
|
||||||
|
r.metadata?.level === 'senior' &&
|
||||||
|
r.metadata?.location === 'SF'
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle boolean filters', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { remote: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r => r.metadata?.remote === true)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle $in operator', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
skill: { $in: ['React', 'Vue', 'TypeScript'] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r =>
|
||||||
|
['React', 'Vue', 'TypeScript'].includes(r.metadata?.skill)
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle combined filters with $and', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
$and: [
|
||||||
|
{ level: 'senior' },
|
||||||
|
{ remote: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r =>
|
||||||
|
r.metadata?.level === 'senior' &&
|
||||||
|
r.metadata?.remote === true
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle $or operator', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
$or: [
|
||||||
|
{ location: 'SF' },
|
||||||
|
{ location: 'NYC' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r =>
|
||||||
|
r.metadata?.location === 'SF' ||
|
||||||
|
r.metadata?.location === 'NYC'
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty results when no items match filter', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
level: 'expert' // Non-existent level
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work with searchWithinItems', async () => {
|
||||||
|
// First get all senior developers
|
||||||
|
const allItems = await brainy.getNouns({
|
||||||
|
filter: {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const seniorIds = allItems.items.map(item => item.id)
|
||||||
|
|
||||||
|
// Search within senior developers only
|
||||||
|
const results = await brainy.searchWithinItems(
|
||||||
|
'JavaScript',
|
||||||
|
seniorIds,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(results.every(r => seniorIds.includes(r.id))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle metadata updates correctly', async () => {
|
||||||
|
// Add an item
|
||||||
|
const id = await brainy.add('Test developer', { level: 'junior' })
|
||||||
|
|
||||||
|
// Search should find it with junior filter
|
||||||
|
let results = await brainy.searchText('Test developer', 10, {
|
||||||
|
metadata: { level: 'junior' }
|
||||||
|
})
|
||||||
|
expect(results.some(r => r.id === id)).toBe(true)
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await brainy.updateMetadata(id, { level: 'senior' })
|
||||||
|
|
||||||
|
// Should now find it with senior filter
|
||||||
|
results = await brainy.searchText('Test developer', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
expect(results.some(r => r.id === id)).toBe(true)
|
||||||
|
|
||||||
|
// Should NOT find it with junior filter anymore
|
||||||
|
results = await brainy.searchText('Test developer', 10, {
|
||||||
|
metadata: { level: 'junior' }
|
||||||
|
})
|
||||||
|
expect(results.some(r => r.id === id)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null/undefined metadata gracefully', async () => {
|
||||||
|
// Add item without metadata
|
||||||
|
await brainy.add('No metadata item')
|
||||||
|
|
||||||
|
// Search with filter should not crash
|
||||||
|
const results = await brainy.searchText('metadata', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should only return items that match the filter
|
||||||
|
expect(results.every(r => r.metadata?.level === 'senior')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Performance considerations', () => {
|
||||||
|
it('should handle large result sets efficiently', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 16, efConstruction: 100 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add many items
|
||||||
|
const categories = ['A', 'B', 'C', 'D', 'E']
|
||||||
|
const levels = ['junior', 'mid', 'senior']
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await brainy.add(
|
||||||
|
`Item ${i} with various properties`,
|
||||||
|
{
|
||||||
|
category: categories[i % categories.length],
|
||||||
|
level: levels[i % levels.length],
|
||||||
|
index: i
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Search with complex filter
|
||||||
|
const results = await brainy.searchText('Item', 20, {
|
||||||
|
metadata: {
|
||||||
|
$and: [
|
||||||
|
{ category: { $in: ['A', 'B', 'C'] } },
|
||||||
|
{ level: { $ne: 'junior' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.length).toBeLessThanOrEqual(20)
|
||||||
|
expect(duration).toBeLessThan(1000) // Should complete within 1 second
|
||||||
|
|
||||||
|
// Verify all results match the filter
|
||||||
|
expect(results.every(r => {
|
||||||
|
const m = r.metadata
|
||||||
|
return ['A', 'B', 'C'].includes(m?.category) && m?.level !== 'junior'
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
269
tests/metadata-filter.test.ts
Normal file
269
tests/metadata-filter.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { matchesMetadataFilter } from '../src/utils/metadataFilter.js'
|
||||||
|
|
||||||
|
describe('Metadata Filtering', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 8, efConstruction: 50 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
console.log('BrainyData initialized')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchesMetadataFilter', () => {
|
||||||
|
it('should match simple equality filters', () => {
|
||||||
|
const metadata = { level: 'senior', location: 'SF' }
|
||||||
|
|
||||||
|
expect(matchesMetadataFilter(metadata, { level: 'senior' })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { level: 'junior' })).toBe(false)
|
||||||
|
expect(matchesMetadataFilter(metadata, { level: 'senior', location: 'SF' })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { level: 'senior', location: 'NYC' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support MongoDB-style operators', () => {
|
||||||
|
const metadata = { age: 30, skills: ['React', 'Vue'], name: 'John' }
|
||||||
|
|
||||||
|
// $gt, $gte, $lt, $lte
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $gt: 25 } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $lt: 25 } })).toBe(false)
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $gte: 30 } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $lte: 30 } })).toBe(true)
|
||||||
|
|
||||||
|
// $in, $nin
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $in: [25, 30, 35] } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $nin: [25, 35] } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { age: { $nin: [30] } })).toBe(false)
|
||||||
|
|
||||||
|
// $includes for arrays
|
||||||
|
expect(matchesMetadataFilter(metadata, { skills: { $includes: 'React' } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { skills: { $includes: 'Angular' } })).toBe(false)
|
||||||
|
|
||||||
|
// $regex
|
||||||
|
expect(matchesMetadataFilter(metadata, { name: { $regex: '^Jo' } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { name: { $regex: 'hn$' } })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { name: { $regex: 'Jane' } })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support nested fields with dot notation', () => {
|
||||||
|
const metadata = {
|
||||||
|
user: {
|
||||||
|
profile: {
|
||||||
|
level: 'senior',
|
||||||
|
skills: ['React', 'TypeScript']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(matchesMetadataFilter(metadata, { 'user.profile.level': 'senior' })).toBe(true)
|
||||||
|
expect(matchesMetadataFilter(metadata, { 'user.profile.level': 'junior' })).toBe(false)
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
'user.profile.skills': { $includes: 'React' }
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support logical operators', () => {
|
||||||
|
const metadata = { level: 'senior', location: 'SF', remote: true }
|
||||||
|
|
||||||
|
// $and
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$and: [
|
||||||
|
{ level: 'senior' },
|
||||||
|
{ location: 'SF' }
|
||||||
|
]
|
||||||
|
})).toBe(true)
|
||||||
|
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$and: [
|
||||||
|
{ level: 'senior' },
|
||||||
|
{ location: 'NYC' }
|
||||||
|
]
|
||||||
|
})).toBe(false)
|
||||||
|
|
||||||
|
// $or
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$or: [
|
||||||
|
{ location: 'NYC' },
|
||||||
|
{ location: 'SF' }
|
||||||
|
]
|
||||||
|
})).toBe(true)
|
||||||
|
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$or: [
|
||||||
|
{ location: 'NYC' },
|
||||||
|
{ location: 'LA' }
|
||||||
|
]
|
||||||
|
})).toBe(false)
|
||||||
|
|
||||||
|
// $not
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$not: { location: 'NYC' }
|
||||||
|
})).toBe(true)
|
||||||
|
|
||||||
|
expect(matchesMetadataFilter(metadata, {
|
||||||
|
$not: { location: 'SF' }
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search with metadata filtering', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add test data
|
||||||
|
const developers = [
|
||||||
|
{ name: 'Alice', level: 'senior', skills: ['React', 'TypeScript'], location: 'SF', available: true },
|
||||||
|
{ name: 'Bob', level: 'mid', skills: ['Vue', 'JavaScript'], location: 'NYC', available: true },
|
||||||
|
{ name: 'Charlie', level: 'senior', skills: ['React', 'Python'], location: 'SF', available: false },
|
||||||
|
{ name: 'David', level: 'junior', skills: ['JavaScript'], location: 'LA', available: true },
|
||||||
|
{ name: 'Eve', level: 'senior', skills: ['Angular', 'TypeScript'], location: 'NYC', available: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const dev of developers) {
|
||||||
|
await brainy.add(
|
||||||
|
`${dev.name} is a ${dev.level} developer with ${dev.skills.join(', ')} skills in ${dev.location}`,
|
||||||
|
dev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter by simple metadata fields', async () => {
|
||||||
|
// First check what we have without filter
|
||||||
|
const allResults = await brainy.searchText('developer', 10)
|
||||||
|
console.log('All results:', allResults.map(r => ({
|
||||||
|
id: r.id.substring(0, 8),
|
||||||
|
level: r.metadata?.level,
|
||||||
|
name: r.metadata?.name
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Now with filter
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Filtered results:', results.map(r => ({
|
||||||
|
id: r.id.substring(0, 8),
|
||||||
|
level: r.metadata?.level,
|
||||||
|
name: r.metadata?.name
|
||||||
|
})))
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r => r.metadata?.level === 'senior')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter by multiple metadata fields', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
level: 'senior',
|
||||||
|
location: 'SF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r =>
|
||||||
|
r.metadata?.level === 'senior' &&
|
||||||
|
r.metadata?.location === 'SF'
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter with MongoDB operators', async () => {
|
||||||
|
// First verify what we have in the index
|
||||||
|
const allResults = await brainy.searchText('developer', 10)
|
||||||
|
console.log('All results before filtering:', allResults.map(r => ({
|
||||||
|
name: r.metadata?.name,
|
||||||
|
skills: r.metadata?.skills,
|
||||||
|
available: r.metadata?.available
|
||||||
|
})))
|
||||||
|
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
skills: { $includes: 'React' },
|
||||||
|
available: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Filtered results:', results.map(r => ({
|
||||||
|
name: r.metadata?.name,
|
||||||
|
skills: r.metadata?.skills,
|
||||||
|
available: r.metadata?.available
|
||||||
|
})))
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Check each result individually for debugging
|
||||||
|
for (const r of results) {
|
||||||
|
const hasReact = r.metadata?.skills?.includes('React')
|
||||||
|
const isAvailable = r.metadata?.available === true
|
||||||
|
if (!hasReact || !isAvailable) {
|
||||||
|
console.log('Failed result:', r.metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.every(r =>
|
||||||
|
r.metadata?.skills?.includes('React') &&
|
||||||
|
r.metadata?.available === true
|
||||||
|
)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter with complex queries', async () => {
|
||||||
|
const results = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: {
|
||||||
|
$or: [
|
||||||
|
{ location: 'SF' },
|
||||||
|
{ location: 'NYC' }
|
||||||
|
],
|
||||||
|
level: { $in: ['senior', 'mid'] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results.every(r => {
|
||||||
|
const m = r.metadata
|
||||||
|
return (m?.location === 'SF' || m?.location === 'NYC') &&
|
||||||
|
(m?.level === 'senior' || m?.level === 'mid')
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('searchWithinItems', () => {
|
||||||
|
let itemIds: string[] = []
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add test data and collect IDs
|
||||||
|
const items = [
|
||||||
|
{ content: 'JavaScript programming', category: 'tech' },
|
||||||
|
{ content: 'TypeScript development', category: 'tech' },
|
||||||
|
{ content: 'Python data science', category: 'tech' },
|
||||||
|
{ content: 'React components', category: 'frontend' },
|
||||||
|
{ content: 'Vue templates', category: 'frontend' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const id = await brainy.add(item.content, item)
|
||||||
|
if (item.category === 'frontend') {
|
||||||
|
itemIds.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search only within specified items', async () => {
|
||||||
|
// Search within frontend items only
|
||||||
|
const results = await brainy.searchWithinItems('JavaScript', itemIds, 5)
|
||||||
|
|
||||||
|
expect(results.length).toBeLessThanOrEqual(itemIds.length)
|
||||||
|
expect(results.every(r => itemIds.includes(r.id))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty results if no items match', async () => {
|
||||||
|
const results = await brainy.searchWithinItems('JavaScript', [], 5)
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should limit results to k even if more items are provided', async () => {
|
||||||
|
const results = await brainy.searchWithinItems('development', itemIds, 1)
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
568
tests/metadata-performance.test.ts
Normal file
568
tests/metadata-performance.test.ts
Normal file
|
|
@ -0,0 +1,568 @@
|
||||||
|
/**
|
||||||
|
* Metadata Filtering Performance Analysis
|
||||||
|
*
|
||||||
|
* This test suite analyzes the performance impact of the metadata filtering system:
|
||||||
|
* 1. Index Build Time - How metadata indexing affects initialization
|
||||||
|
* 2. Index Storage Overhead - Storage space required for inverted indexes
|
||||||
|
* 3. Search Performance - Filtered vs non-filtered search speeds
|
||||||
|
* 4. Memory Usage - Additional memory needed for metadata indexes
|
||||||
|
* 5. Write Performance - Impact on add/update/delete operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { MetadataIndexManager } from '../src/utils/metadataIndex.js'
|
||||||
|
|
||||||
|
// Helper function to measure execution time
|
||||||
|
const measureTime = async (fn: () => Promise<any>): Promise<{ result: any, time: number }> => {
|
||||||
|
const start = performance.now()
|
||||||
|
const result = await fn()
|
||||||
|
const end = performance.now()
|
||||||
|
return { result, time: end - start }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to estimate memory usage
|
||||||
|
const measureMemory = () => {
|
||||||
|
if (typeof performance.memory !== 'undefined') {
|
||||||
|
return {
|
||||||
|
used: performance.memory.usedJSHeapSize,
|
||||||
|
total: performance.memory.totalJSHeapSize,
|
||||||
|
limit: performance.memory.jsHeapSizeLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate realistic test data with metadata
|
||||||
|
const generateTestDataWithMetadata = (count: number) => {
|
||||||
|
const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Operations']
|
||||||
|
const levels = ['junior', 'senior', 'staff', 'principal', 'director']
|
||||||
|
const locations = ['SF', 'NYC', 'LA', 'Seattle', 'Austin', 'Boston']
|
||||||
|
const skills = ['JavaScript', 'Python', 'React', 'Node.js', 'TypeScript', 'SQL', 'AWS', 'Docker']
|
||||||
|
const companies = ['TechCorp', 'DataSys', 'CloudInc', 'DevTools', 'AILabs']
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
text: `Profile ${i}: Professional with extensive experience in software development and team leadership`,
|
||||||
|
metadata: {
|
||||||
|
id: `profile-${i}`,
|
||||||
|
department: departments[i % departments.length],
|
||||||
|
level: levels[i % levels.length],
|
||||||
|
location: locations[i % locations.length],
|
||||||
|
salary: 50000 + (i % 10) * 10000,
|
||||||
|
experience: 1 + (i % 15),
|
||||||
|
skills: skills.slice(0, 2 + (i % 4)),
|
||||||
|
company: companies[i % companies.length],
|
||||||
|
remote: i % 3 === 0,
|
||||||
|
active: i % 5 !== 0,
|
||||||
|
tags: [`tag-${i % 20}`, `category-${i % 10}`],
|
||||||
|
nested: {
|
||||||
|
profile: {
|
||||||
|
rating: 1 + (i % 5),
|
||||||
|
verified: i % 4 === 0
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
timezone: `UTC-${(i % 12) - 6}`,
|
||||||
|
workStyle: i % 2 === 0 ? 'collaborative' : 'independent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Metadata Filtering Performance Analysis', () => {
|
||||||
|
describe('1. Index Build Time Impact', () => {
|
||||||
|
it('should measure initialization time with vs without metadata indexing', async () => {
|
||||||
|
const testData = generateTestDataWithMetadata(500)
|
||||||
|
|
||||||
|
console.log('\n=== Index Build Time Analysis ===')
|
||||||
|
|
||||||
|
// Test WITHOUT metadata indexing
|
||||||
|
const withoutIndexing = await measureTime(async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 8, efConstruction: 50 },
|
||||||
|
logging: { verbose: false }
|
||||||
|
// No metadataIndex config
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return brainy
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`WITHOUT indexing: ${withoutIndexing.time.toFixed(2)}ms for 500 items`)
|
||||||
|
console.log(`Per item: ${(withoutIndexing.time / 500).toFixed(2)}ms`)
|
||||||
|
|
||||||
|
// Test WITH metadata indexing
|
||||||
|
const withIndexing = await measureTime(async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 8, efConstruction: 50 },
|
||||||
|
logging: { verbose: false },
|
||||||
|
metadataIndex: {
|
||||||
|
maxIndexSize: 10000,
|
||||||
|
autoOptimize: true,
|
||||||
|
excludeFields: ['id']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return brainy
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`WITH indexing: ${withIndexing.time.toFixed(2)}ms for 500 items`)
|
||||||
|
console.log(`Per item: ${(withIndexing.time / 500).toFixed(2)}ms`)
|
||||||
|
|
||||||
|
const overhead = ((withIndexing.time - withoutIndexing.time) / withoutIndexing.time) * 100
|
||||||
|
console.log(`Index build overhead: ${overhead.toFixed(1)}%`)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await withoutIndexing.result.shutDown()
|
||||||
|
await withIndexing.result.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should measure batch insert performance with indexing', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const batchSizes = [50, 100, 200, 500]
|
||||||
|
console.log('\n=== Batch Insert Performance ===')
|
||||||
|
|
||||||
|
for (const size of batchSizes) {
|
||||||
|
const testData = generateTestDataWithMetadata(size)
|
||||||
|
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${size} items: ${time.toFixed(2)}ms (${(time / size).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Clear for next batch
|
||||||
|
await brainy.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('2. Index Storage Overhead', () => {
|
||||||
|
it('should analyze storage requirements for metadata indexes', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const testData = generateTestDataWithMetadata(1000)
|
||||||
|
|
||||||
|
console.log('\n=== Storage Overhead Analysis ===')
|
||||||
|
|
||||||
|
// Add data and measure index size
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.addNoun(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get index statistics
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
const stats = await brainy.metadataIndex.getStats()
|
||||||
|
console.log(`Total index entries: ${stats.totalEntries}`)
|
||||||
|
console.log(`Total indexed IDs: ${stats.totalIds}`)
|
||||||
|
console.log(`Fields indexed: ${stats.fieldsIndexed.length}`)
|
||||||
|
console.log(`Estimated index size: ${stats.indexSize} bytes`)
|
||||||
|
console.log(`Fields: ${stats.fieldsIndexed.join(', ')}`)
|
||||||
|
|
||||||
|
// Calculate overhead per item
|
||||||
|
const overheadPerItem = stats.indexSize / 1000
|
||||||
|
console.log(`Storage overhead per item: ${overheadPerItem.toFixed(2)} bytes`)
|
||||||
|
|
||||||
|
// Estimate total storage efficiency
|
||||||
|
const totalDataSize = 1000 * 200 // rough estimate of 200 bytes per item
|
||||||
|
const storageEfficiency = (stats.indexSize / totalDataSize) * 100
|
||||||
|
console.log(`Index storage overhead: ${storageEfficiency.toFixed(1)}% of data size`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('3. Search Performance Comparison', () => {
|
||||||
|
it('should compare filtered vs non-filtered search performance', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
const testData = generateTestDataWithMetadata(1000)
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.addNoun(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Search Performance Comparison ===')
|
||||||
|
|
||||||
|
const searchQuery = 'Professional software development experience'
|
||||||
|
const numSearches = 10
|
||||||
|
|
||||||
|
// Test 1: No filtering
|
||||||
|
const noFilterTimes: number[] = []
|
||||||
|
for (let i = 0; i < numSearches; i++) {
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
return await brainy.search(searchQuery, 20)
|
||||||
|
})
|
||||||
|
noFilterTimes.push(time)
|
||||||
|
}
|
||||||
|
const avgNoFilter = noFilterTimes.reduce((a, b) => a + b) / numSearches
|
||||||
|
console.log(`No filtering: ${avgNoFilter.toFixed(2)}ms average`)
|
||||||
|
|
||||||
|
// Test 2: Simple metadata filtering (high selectivity)
|
||||||
|
const simpleFilterTimes: number[] = []
|
||||||
|
for (let i = 0; i < numSearches; i++) {
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
return await brainy.search(searchQuery, 20, {
|
||||||
|
metadata: { department: 'Engineering' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
simpleFilterTimes.push(time)
|
||||||
|
}
|
||||||
|
const avgSimpleFilter = simpleFilterTimes.reduce((a, b) => a + b) / numSearches
|
||||||
|
console.log(`Simple filter (dept=Engineering): ${avgSimpleFilter.toFixed(2)}ms average`)
|
||||||
|
|
||||||
|
// Test 3: Complex metadata filtering (low selectivity)
|
||||||
|
const complexFilterTimes: number[] = []
|
||||||
|
for (let i = 0; i < numSearches; i++) {
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
return await brainy.search(searchQuery, 20, {
|
||||||
|
metadata: {
|
||||||
|
department: { $in: ['Engineering', 'Marketing'] },
|
||||||
|
level: { $in: ['senior', 'staff'] },
|
||||||
|
salary: { $gte: 80000 },
|
||||||
|
remote: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
complexFilterTimes.push(time)
|
||||||
|
}
|
||||||
|
const avgComplexFilter = complexFilterTimes.reduce((a, b) => a + b) / numSearches
|
||||||
|
console.log(`Complex filter: ${avgComplexFilter.toFixed(2)}ms average`)
|
||||||
|
|
||||||
|
// Test 4: Nested field filtering
|
||||||
|
const nestedFilterTimes: number[] = []
|
||||||
|
for (let i = 0; i < numSearches; i++) {
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
return await brainy.search(searchQuery, 20, {
|
||||||
|
metadata: {
|
||||||
|
'nested.profile.rating': { $gte: 4 },
|
||||||
|
'nested.profile.verified': true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
nestedFilterTimes.push(time)
|
||||||
|
}
|
||||||
|
const avgNestedFilter = nestedFilterTimes.reduce((a, b) => a + b) / numSearches
|
||||||
|
console.log(`Nested filter: ${avgNestedFilter.toFixed(2)}ms average`)
|
||||||
|
|
||||||
|
// Performance analysis
|
||||||
|
console.log('\nPerformance Impact:')
|
||||||
|
console.log(`Simple filter overhead: ${((avgSimpleFilter / avgNoFilter - 1) * 100).toFixed(1)}%`)
|
||||||
|
console.log(`Complex filter overhead: ${((avgComplexFilter / avgNoFilter - 1) * 100).toFixed(1)}%`)
|
||||||
|
console.log(`Nested filter overhead: ${((avgNestedFilter / avgNoFilter - 1) * 100).toFixed(1)}%`)
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test search performance with different ef multipliers', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
hnsw: { efSearch: 50 }, // Base ef for testing multiplier effect
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
const testData = generateTestDataWithMetadata(500)
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.addNoun(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== EF Multiplier Impact Analysis ===')
|
||||||
|
|
||||||
|
const searchQuery = 'Professional software development experience'
|
||||||
|
|
||||||
|
// Test with different selectivity filters
|
||||||
|
const filters = [
|
||||||
|
{ name: 'High selectivity', filter: { department: 'Engineering' }, expected: '~17%' },
|
||||||
|
{ name: 'Medium selectivity', filter: { level: { $in: ['senior', 'staff'] } }, expected: '~40%' },
|
||||||
|
{ name: 'Low selectivity', filter: { active: true }, expected: '~80%' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { name, filter, expected } of filters) {
|
||||||
|
const { result, time } = await measureTime(async () => {
|
||||||
|
return await brainy.search(searchQuery, 10, { metadata: filter })
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${name} (${expected}): ${time.toFixed(2)}ms, ${result.length} results`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('4. Memory Usage Analysis', () => {
|
||||||
|
it('should measure memory consumption of metadata indexes', async () => {
|
||||||
|
if (!measureMemory()) {
|
||||||
|
console.log('\nMemory measurement not available in this environment')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Memory Usage Analysis ===')
|
||||||
|
|
||||||
|
const initialMemory = measureMemory()!
|
||||||
|
console.log(`Initial memory: ${(initialMemory.used / 1024 / 1024).toFixed(2)}MB`)
|
||||||
|
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const afterInitMemory = measureMemory()!
|
||||||
|
console.log(`After init: ${(afterInitMemory.used / 1024 / 1024).toFixed(2)}MB`)
|
||||||
|
|
||||||
|
// Add data in batches and measure memory growth
|
||||||
|
const batchSize = 100
|
||||||
|
const numBatches = 5
|
||||||
|
|
||||||
|
for (let batch = 1; batch <= numBatches; batch++) {
|
||||||
|
const testData = generateTestDataWithMetadata(batchSize)
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMemory = measureMemory()!
|
||||||
|
const totalItems = batch * batchSize
|
||||||
|
console.log(`${totalItems} items: ${(currentMemory.used / 1024 / 1024).toFixed(2)}MB`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get final index stats
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
const stats = await brainy.metadataIndex.getStats()
|
||||||
|
console.log(`Index entries: ${stats.totalEntries}, Memory per entry: ${((measureMemory()!.used - initialMemory.used) / stats.totalEntries).toFixed(2)} bytes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('5. Write Performance Impact', () => {
|
||||||
|
it('should measure add/update/delete performance with indexing', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('\n=== Write Performance Analysis ===')
|
||||||
|
|
||||||
|
// Test ADD performance
|
||||||
|
const testData = generateTestDataWithMetadata(200)
|
||||||
|
const { time: addTime } = await measureTime(async () => {
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`ADD: 200 items in ${addTime.toFixed(2)}ms (${(addTime / 200).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Test UPDATE performance
|
||||||
|
const updateData = testData.slice(0, 50).map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
metadata: {
|
||||||
|
...item.metadata,
|
||||||
|
level: 'updated-level',
|
||||||
|
salary: item.metadata.salary + 10000,
|
||||||
|
updateCount: i
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { time: updateTime } = await measureTime(async () => {
|
||||||
|
for (const item of updateData) {
|
||||||
|
await brainy.updateMetadata(item.metadata.id, item.metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`UPDATE: 50 items in ${updateTime.toFixed(2)}ms (${(updateTime / 50).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Test DELETE performance
|
||||||
|
const idsToDelete = testData.slice(100, 150).map(item => item.metadata.id)
|
||||||
|
const { time: deleteTime } = await measureTime(async () => {
|
||||||
|
for (const id of idsToDelete) {
|
||||||
|
await brainy.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(`DELETE: 50 items in ${deleteTime.toFixed(2)}ms (${(deleteTime / 50).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Verify index consistency
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
const stats = await brainy.metadataIndex.getStats()
|
||||||
|
console.log(`Final index state: ${stats.totalEntries} entries, ${stats.totalIds} IDs`)
|
||||||
|
|
||||||
|
// Should have 150 items remaining (200 - 50 deleted)
|
||||||
|
const expectedItems = 200 - 50
|
||||||
|
const actualItems = await brainy.size()
|
||||||
|
console.log(`Data consistency: ${actualItems}/${expectedItems} items remaining`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test concurrent write performance', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: { autoOptimize: true },
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('\n=== Concurrent Write Performance ===')
|
||||||
|
|
||||||
|
const testData = generateTestDataWithMetadata(100)
|
||||||
|
|
||||||
|
// Sequential writes
|
||||||
|
const { time: sequentialTime } = await measureTime(async () => {
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.add(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainy.clear()
|
||||||
|
|
||||||
|
// Concurrent writes (batched)
|
||||||
|
const batchSize = 20
|
||||||
|
const { time: concurrentTime } = await measureTime(async () => {
|
||||||
|
const promises: Promise<any>[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < testData.length; i += batchSize) {
|
||||||
|
const batch = testData.slice(i, i + batchSize)
|
||||||
|
promises.push(
|
||||||
|
Promise.all(batch.map(item => brainy.add(item.text, item.metadata)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Sequential: ${sequentialTime.toFixed(2)}ms`)
|
||||||
|
console.log(`Concurrent (batched): ${concurrentTime.toFixed(2)}ms`)
|
||||||
|
console.log(`Speedup: ${(sequentialTime / concurrentTime).toFixed(2)}x`)
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('6. Index Maintenance and Optimization', () => {
|
||||||
|
it('should analyze index rebuild performance', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: {
|
||||||
|
autoOptimize: true,
|
||||||
|
rebuildThreshold: 0.1
|
||||||
|
},
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('\n=== Index Maintenance Analysis ===')
|
||||||
|
|
||||||
|
// Add initial data
|
||||||
|
const testData = generateTestDataWithMetadata(300)
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.addNoun(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure manual rebuild
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
const { time: rebuildTime } = await measureTime(async () => {
|
||||||
|
await brainy.metadataIndex!.rebuild()
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = await brainy.metadataIndex.getStats()
|
||||||
|
console.log(`Rebuild: ${rebuildTime.toFixed(2)}ms for ${stats.totalEntries} entries`)
|
||||||
|
console.log(`Per entry: ${(rebuildTime / stats.totalEntries).toFixed(2)}ms`)
|
||||||
|
|
||||||
|
// Test flush performance
|
||||||
|
const { time: flushTime } = await measureTime(async () => {
|
||||||
|
await brainy.metadataIndex!.flush()
|
||||||
|
})
|
||||||
|
console.log(`Flush: ${flushTime.toFixed(2)}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test index cache performance', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
metadataIndex: {
|
||||||
|
maxIndexSize: 1000,
|
||||||
|
autoOptimize: true
|
||||||
|
},
|
||||||
|
logging: { verbose: false }
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('\n=== Index Cache Performance ===')
|
||||||
|
|
||||||
|
// Add test data
|
||||||
|
const testData = generateTestDataWithMetadata(200)
|
||||||
|
for (const item of testData) {
|
||||||
|
await brainy.addNoun(item.text, item.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!brainy.metadataIndex) return
|
||||||
|
|
||||||
|
// Test cache hit performance (repeated queries)
|
||||||
|
const filter = { department: 'Engineering' }
|
||||||
|
|
||||||
|
// First query (cache miss)
|
||||||
|
const { time: cacheMissTime } = await measureTime(async () => {
|
||||||
|
return await brainy.metadataIndex!.getIdsForCriteria(filter)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subsequent queries (cache hits)
|
||||||
|
const cacheHitTimes: number[] = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const { time } = await measureTime(async () => {
|
||||||
|
return await brainy.metadataIndex!.getIdsForCriteria(filter)
|
||||||
|
})
|
||||||
|
cacheHitTimes.push(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgCacheHit = cacheHitTimes.reduce((a, b) => a + b) / cacheHitTimes.length
|
||||||
|
console.log(`Cache miss: ${cacheMissTime.toFixed(2)}ms`)
|
||||||
|
console.log(`Cache hit (avg): ${avgCacheHit.toFixed(2)}ms`)
|
||||||
|
console.log(`Cache speedup: ${(cacheMissTime / avgCacheHit).toFixed(2)}x`)
|
||||||
|
|
||||||
|
await brainy.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
241
tests/mocks/opfs-mock.ts
Normal file
241
tests/mocks/opfs-mock.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
/**
|
||||||
|
* OPFS (Origin Private File System) Mock for Testing
|
||||||
|
*
|
||||||
|
* This module provides a comprehensive mock implementation of the OPFS API
|
||||||
|
* for testing OPFS-based storage in a Node.js environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// In-memory storage to simulate file system
|
||||||
|
const mockFileSystem: Map<string, Map<string, any>> = new Map()
|
||||||
|
|
||||||
|
// Mock file data
|
||||||
|
interface MockFileData {
|
||||||
|
content: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock FileSystemFileHandle
|
||||||
|
*/
|
||||||
|
export function createMockFileHandle(fileName: string, content: string = '{}') {
|
||||||
|
return {
|
||||||
|
kind: 'file',
|
||||||
|
name: fileName,
|
||||||
|
getFile: vi.fn().mockResolvedValue({
|
||||||
|
text: vi.fn().mockResolvedValue(content),
|
||||||
|
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode(content).buffer),
|
||||||
|
size: content.length
|
||||||
|
}),
|
||||||
|
createWritable: vi.fn().mockImplementation(() => {
|
||||||
|
const writable = {
|
||||||
|
write: vi.fn().mockImplementation((data: string | ArrayBuffer) => {
|
||||||
|
// Store the data in our mock file system
|
||||||
|
const path = mockFileSystem.get('currentPath') || '/'
|
||||||
|
const dirMap = mockFileSystem.get(path) || new Map()
|
||||||
|
|
||||||
|
let content: string
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
content = data
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
content = new TextDecoder().decode(data)
|
||||||
|
} else if (data && typeof data === 'object' && 'type' in data && data.type === 'write') {
|
||||||
|
// Handle FileSystemWriteChunkType
|
||||||
|
const chunk = data as { type: 'write', position?: number, data: string | ArrayBuffer }
|
||||||
|
if (typeof chunk.data === 'string') {
|
||||||
|
content = chunk.data
|
||||||
|
} else {
|
||||||
|
content = new TextDecoder().decode(chunk.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = JSON.stringify(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirMap.set(fileName, { content, type: 'file' })
|
||||||
|
mockFileSystem.set(path, dirMap)
|
||||||
|
return Promise.resolve()
|
||||||
|
}),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
return Promise.resolve(writable)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock FileSystemDirectoryHandle
|
||||||
|
*/
|
||||||
|
export function createMockDirectoryHandle(dirName: string, entries: Map<string, any> = new Map()) {
|
||||||
|
const dirPath = mockFileSystem.get('currentPath') || '/'
|
||||||
|
const fullPath = dirPath === '/' ? `/${dirName}` : `${dirPath}/${dirName}`
|
||||||
|
|
||||||
|
// Store the directory in our mock file system
|
||||||
|
mockFileSystem.set(fullPath, entries)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'directory',
|
||||||
|
name: dirName,
|
||||||
|
getDirectoryHandle: vi.fn().mockImplementation((name: string, options: { create?: boolean } = {}) => {
|
||||||
|
mockFileSystem.set('currentPath', fullPath)
|
||||||
|
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
const entry = dirEntries.get(name)
|
||||||
|
|
||||||
|
if (entry && entry.type === 'directory') {
|
||||||
|
return Promise.resolve(createMockDirectoryHandle(name, entry.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry && options.create) {
|
||||||
|
const newDir = new Map()
|
||||||
|
dirEntries.set(name, { content: newDir, type: 'directory' })
|
||||||
|
mockFileSystem.set(fullPath, dirEntries)
|
||||||
|
return Promise.resolve(createMockDirectoryHandle(name, newDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Directory not found: ${name}`))
|
||||||
|
}),
|
||||||
|
getFileHandle: vi.fn().mockImplementation((name: string, options: { create?: boolean } = {}) => {
|
||||||
|
mockFileSystem.set('currentPath', fullPath)
|
||||||
|
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
const entry = dirEntries.get(name)
|
||||||
|
|
||||||
|
if (entry && entry.type === 'file') {
|
||||||
|
return Promise.resolve(createMockFileHandle(name, entry.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry && options.create) {
|
||||||
|
return Promise.resolve(createMockFileHandle(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`File not found: ${name}`))
|
||||||
|
}),
|
||||||
|
removeEntry: vi.fn().mockImplementation((name: string, options: { recursive?: boolean } = {}) => {
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
|
||||||
|
if (!dirEntries.has(name)) {
|
||||||
|
return Promise.reject(new Error(`Entry not found: ${name}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = dirEntries.get(name)
|
||||||
|
|
||||||
|
if (entry.type === 'directory' && !options.recursive) {
|
||||||
|
const subDirPath = fullPath === '/' ? `/${name}` : `${fullPath}/${name}`
|
||||||
|
const subDirEntries = mockFileSystem.get(subDirPath) || new Map()
|
||||||
|
|
||||||
|
if (subDirEntries.size > 0) {
|
||||||
|
return Promise.reject(new Error(`Directory not empty: ${name}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirEntries.delete(name)
|
||||||
|
|
||||||
|
if (entry.type === 'directory') {
|
||||||
|
const subDirPath = fullPath === '/' ? `/${name}` : `${fullPath}/${name}`
|
||||||
|
mockFileSystem.delete(subDirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
}),
|
||||||
|
entries: vi.fn().mockImplementation(async function* () {
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
|
||||||
|
for (const [name, entry] of dirEntries.entries()) {
|
||||||
|
if (entry.type === 'file') {
|
||||||
|
yield [name, createMockFileHandle(name, entry.content)]
|
||||||
|
} else {
|
||||||
|
yield [name, createMockDirectoryHandle(name, entry.content)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
values: vi.fn().mockImplementation(async function* () {
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
|
||||||
|
for (const [name, entry] of dirEntries.entries()) {
|
||||||
|
if (entry.type === 'file') {
|
||||||
|
yield createMockFileHandle(name, entry.content)
|
||||||
|
} else {
|
||||||
|
yield createMockDirectoryHandle(name, entry.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
keys: vi.fn().mockImplementation(async function* () {
|
||||||
|
const dirEntries = mockFileSystem.get(fullPath) || new Map()
|
||||||
|
|
||||||
|
for (const name of dirEntries.keys()) {
|
||||||
|
yield name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup OPFS mock environment
|
||||||
|
*/
|
||||||
|
export function setupOPFSMock() {
|
||||||
|
// Clear the mock file system
|
||||||
|
mockFileSystem.clear()
|
||||||
|
mockFileSystem.set('/', new Map())
|
||||||
|
mockFileSystem.set('currentPath', '/')
|
||||||
|
|
||||||
|
// Create root directory handle
|
||||||
|
const rootDirectoryHandle = createMockDirectoryHandle('root')
|
||||||
|
|
||||||
|
// Mock navigator.storage if it doesn't exist
|
||||||
|
if (typeof global.navigator === 'undefined') {
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.navigator = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define storage if it doesn't exist
|
||||||
|
if (typeof global.navigator.storage === 'undefined') {
|
||||||
|
Object.defineProperty(global.navigator, 'storage', {
|
||||||
|
value: {},
|
||||||
|
writable: true,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock storage methods
|
||||||
|
global.navigator.storage.getDirectory = vi.fn().mockResolvedValue(rootDirectoryHandle)
|
||||||
|
global.navigator.storage.persisted = vi.fn().mockResolvedValue(true)
|
||||||
|
global.navigator.storage.persist = vi.fn().mockResolvedValue(true)
|
||||||
|
global.navigator.storage.estimate = vi.fn().mockImplementation(() => {
|
||||||
|
// Calculate total size of all files in the mock file system
|
||||||
|
let totalSize = 0
|
||||||
|
|
||||||
|
for (const [path, entries] of mockFileSystem.entries()) {
|
||||||
|
if (path === 'currentPath') continue
|
||||||
|
|
||||||
|
for (const [_, entry] of entries.entries()) {
|
||||||
|
if (entry.type === 'file') {
|
||||||
|
totalSize += entry.content.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ usage: totalSize, quota: 10 * 1024 * 1024 }) // 10MB quota
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootDirectoryHandle,
|
||||||
|
mockFileSystem,
|
||||||
|
reset: () => {
|
||||||
|
mockFileSystem.clear()
|
||||||
|
mockFileSystem.set('/', new Map())
|
||||||
|
mockFileSystem.set('currentPath', '/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup OPFS mock environment
|
||||||
|
*/
|
||||||
|
export function cleanupOPFSMock() {
|
||||||
|
// Reset mocks
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
|
||||||
|
// Clear the mock file system
|
||||||
|
mockFileSystem.clear()
|
||||||
|
}
|
||||||
576
tests/mocks/s3-mock.ts
Normal file
576
tests/mocks/s3-mock.ts
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
/**
|
||||||
|
* S3 Compatible Storage Mock for Testing
|
||||||
|
*
|
||||||
|
* This module provides a mock implementation of the AWS S3 client
|
||||||
|
* for testing S3-based storage in a Node.js environment without
|
||||||
|
* requiring actual S3 credentials.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// In-memory storage to simulate S3 bucket
|
||||||
|
interface S3MockObject {
|
||||||
|
key: string
|
||||||
|
body: string
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
lastModified: Date
|
||||||
|
contentLength: number
|
||||||
|
contentType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface S3MockBucket {
|
||||||
|
name: string
|
||||||
|
objects: Map<string, S3MockObject>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock S3 storage - use a global variable to ensure persistence between operations
|
||||||
|
// This is important because the mock client is recreated for each command
|
||||||
|
const mockS3Storage = new Map<string, S3MockBucket>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock S3 client
|
||||||
|
*
|
||||||
|
* This function creates a mock S3 client that simulates the behavior of the AWS S3 client.
|
||||||
|
* It's important that all operations use the same instance of mockS3Storage to ensure
|
||||||
|
* that objects are correctly persisted between operations.
|
||||||
|
*/
|
||||||
|
export function createMockS3Client() {
|
||||||
|
// Log the current state of the mock storage
|
||||||
|
console.log(`[MOCK S3] Creating mock S3 client with current storage state:`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log('[MOCK S3] Objects in bucket:')
|
||||||
|
for (const key of bucket.objects.keys()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
send: vi.fn().mockImplementation((command) => {
|
||||||
|
// Log the command for debugging
|
||||||
|
console.log(`[MOCK S3] Received S3 command: ${command.constructor.name}`, command.input)
|
||||||
|
|
||||||
|
// Log the current state of the mock storage before processing the command
|
||||||
|
console.log(`[MOCK S3] Current storage state before processing command:`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log('[MOCK S3] Objects in bucket:')
|
||||||
|
for (const key of bucket.objects.keys()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different command types
|
||||||
|
let result
|
||||||
|
if (command.constructor.name === 'CreateBucketCommand') {
|
||||||
|
result = handleCreateBucket(command)
|
||||||
|
} else if (command.constructor.name === 'HeadBucketCommand') {
|
||||||
|
result = handleHeadBucket(command)
|
||||||
|
} else if (command.constructor.name === 'PutObjectCommand') {
|
||||||
|
result = handlePutObject(command)
|
||||||
|
} else if (command.constructor.name === 'GetObjectCommand') {
|
||||||
|
result = handleGetObject(command)
|
||||||
|
} else if (command.constructor.name === 'DeleteObjectCommand') {
|
||||||
|
result = handleDeleteObject(command)
|
||||||
|
} else if (command.constructor.name === 'ListObjectsV2Command') {
|
||||||
|
result = handleListObjectsV2(command)
|
||||||
|
} else {
|
||||||
|
console.warn(`[MOCK S3] Unhandled S3 command: ${command.constructor.name}`)
|
||||||
|
result = Promise.resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the current state of the mock storage after processing the command
|
||||||
|
result.then(() => {
|
||||||
|
console.log(`[MOCK S3] Storage state after processing command:`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log('[MOCK S3] Objects in bucket:')
|
||||||
|
for (const key of bucket.objects.keys()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(`[MOCK S3] Error processing command:`, error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle CreateBucketCommand
|
||||||
|
*/
|
||||||
|
function handleCreateBucket(command: any) {
|
||||||
|
const { Bucket } = command.input
|
||||||
|
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
mockS3Storage.set(Bucket, {
|
||||||
|
name: Bucket,
|
||||||
|
objects: new Map()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
Location: `/${Bucket}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HeadBucketCommand
|
||||||
|
*/
|
||||||
|
function handleHeadBucket(command: any) {
|
||||||
|
const { Bucket } = command.input
|
||||||
|
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PutObjectCommand
|
||||||
|
*/
|
||||||
|
function handlePutObject(command: any) {
|
||||||
|
const { Bucket, Key, Body, Metadata, ContentType } = command.input
|
||||||
|
|
||||||
|
console.log(`PutObjectCommand for bucket: ${Bucket}, key: ${Key}`)
|
||||||
|
|
||||||
|
// Create bucket if it doesn't exist
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
console.log(`Creating bucket: ${Bucket}`)
|
||||||
|
mockS3Storage.set(Bucket, {
|
||||||
|
name: Bucket,
|
||||||
|
objects: new Map()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = mockS3Storage.get(Bucket)!
|
||||||
|
|
||||||
|
let bodyContent: string
|
||||||
|
if (typeof Body === 'string') {
|
||||||
|
bodyContent = Body
|
||||||
|
} else if (Body instanceof Uint8Array || Body instanceof Buffer) {
|
||||||
|
bodyContent = new TextDecoder().decode(Body)
|
||||||
|
} else if (Body && typeof Body.toString === 'function') {
|
||||||
|
bodyContent = Body.toString()
|
||||||
|
} else {
|
||||||
|
bodyContent = JSON.stringify(Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the key and body content for debugging
|
||||||
|
console.log(`Storing object with key: ${Key}`)
|
||||||
|
console.log(`Body content: ${bodyContent.substring(0, 50)}${bodyContent.length > 50 ? '...' : ''}`)
|
||||||
|
|
||||||
|
// Parse the body content if it's JSON to ensure it's valid
|
||||||
|
try {
|
||||||
|
if (ContentType === 'application/json') {
|
||||||
|
const parsedBody = JSON.parse(bodyContent)
|
||||||
|
console.log(`Parsed JSON body:`, parsedBody)
|
||||||
|
|
||||||
|
// If this is a noun or verb (but NOT metadata), ensure it has an id property
|
||||||
|
if ((Key.includes('/nouns/') || Key.includes('/verbs/')) && !Key.includes('/metadata/')) {
|
||||||
|
if (!parsedBody.id) {
|
||||||
|
console.error(`Warning: Object ${Key} does not have an id property`)
|
||||||
|
// Add id property based on the key name
|
||||||
|
const id = Key.split('/').pop()?.replace('.json', '') || 'unknown'
|
||||||
|
parsedBody.id = id
|
||||||
|
console.log(`Added id property: ${id}`)
|
||||||
|
bodyContent = JSON.stringify(parsedBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing JSON body for ${Key}:`, error)
|
||||||
|
// Continue with the original body content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the object in the bucket
|
||||||
|
bucket.objects.set(Key, {
|
||||||
|
key: Key,
|
||||||
|
body: bodyContent,
|
||||||
|
metadata: Metadata,
|
||||||
|
lastModified: new Date(),
|
||||||
|
contentLength: bodyContent.length,
|
||||||
|
contentType: ContentType
|
||||||
|
})
|
||||||
|
|
||||||
|
// Debug: Log all objects in the bucket after adding the new one
|
||||||
|
console.log(`All objects in bucket ${Bucket} after adding ${Key}:`)
|
||||||
|
for (const [key, obj] of bucket.objects.entries()) {
|
||||||
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a success response
|
||||||
|
const response = {
|
||||||
|
ETag: `"${Math.random().toString(36).substring(2, 15)}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`PutObjectCommand successful for ${Key}`)
|
||||||
|
return Promise.resolve(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GetObjectCommand
|
||||||
|
*/
|
||||||
|
function handleGetObject(command: any) {
|
||||||
|
const { Bucket, Key } = command.input
|
||||||
|
|
||||||
|
console.log(`GetObjectCommand for bucket: ${Bucket}, key: ${Key}`)
|
||||||
|
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
console.log(`Bucket ${Bucket} not found`)
|
||||||
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = mockS3Storage.get(Bucket)!
|
||||||
|
|
||||||
|
// Debug: Log all objects in the bucket
|
||||||
|
console.log(`All objects in bucket ${Bucket}:`)
|
||||||
|
for (const [key, obj] of bucket.objects.entries()) {
|
||||||
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bucket.objects.has(Key)) {
|
||||||
|
console.log(`Object ${Key} not found in bucket ${Bucket}`)
|
||||||
|
// Create proper NoSuchKey error that matches AWS SDK structure
|
||||||
|
const error = new Error(`NoSuchKey: The specified key does not exist.`)
|
||||||
|
error.name = 'NoSuchKey'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = bucket.objects.get(Key)!
|
||||||
|
console.log(`Found object ${Key} in bucket ${Bucket}`)
|
||||||
|
console.log(`Object body: ${object.body.substring(0, 50)}${object.body.length > 50 ? '...' : ''}`)
|
||||||
|
|
||||||
|
// If this is a JSON object, ensure it has the required properties
|
||||||
|
let bodyContent = object.body
|
||||||
|
if (object.contentType === 'application/json') {
|
||||||
|
try {
|
||||||
|
const parsedBody = JSON.parse(bodyContent)
|
||||||
|
console.log(`Parsed JSON body for ${Key}:`, parsedBody)
|
||||||
|
|
||||||
|
// If this is a noun or verb (but NOT metadata), ensure it has an id property
|
||||||
|
if ((Key.includes('/nouns/') || Key.includes('/verbs/')) && !Key.includes('/metadata/')) {
|
||||||
|
if (!parsedBody.id) {
|
||||||
|
console.error(`Warning: Object ${Key} does not have an id property`)
|
||||||
|
// Add id property based on the key name
|
||||||
|
const id = Key.split('/').pop()?.replace('.json', '') || 'unknown'
|
||||||
|
parsedBody.id = id
|
||||||
|
console.log(`Added id property: ${id}`)
|
||||||
|
bodyContent = JSON.stringify(parsedBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing JSON body for ${Key}:`, error)
|
||||||
|
// Continue with the original body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a response object that matches what the S3 SDK would return
|
||||||
|
const response = {
|
||||||
|
Body: {
|
||||||
|
transformToString: () => Promise.resolve(bodyContent),
|
||||||
|
transformToByteArray: () => Promise.resolve(new TextEncoder().encode(bodyContent))
|
||||||
|
},
|
||||||
|
Metadata: object.metadata || {},
|
||||||
|
LastModified: object.lastModified,
|
||||||
|
ContentLength: bodyContent.length,
|
||||||
|
ContentType: object.contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Returning response for ${Key}`)
|
||||||
|
return Promise.resolve(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DeleteObjectCommand
|
||||||
|
*/
|
||||||
|
function handleDeleteObject(command: any) {
|
||||||
|
const { Bucket, Key } = command.input
|
||||||
|
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
return Promise.reject(new Error(`Bucket not found: ${Bucket}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = mockS3Storage.get(Bucket)!
|
||||||
|
|
||||||
|
if (!bucket.objects.has(Key)) {
|
||||||
|
return Promise.reject(new Error(`Object not found: ${Key}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.objects.delete(Key)
|
||||||
|
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ListObjectsV2Command
|
||||||
|
*/
|
||||||
|
function handleListObjectsV2(command: any) {
|
||||||
|
const { Bucket, Prefix, MaxKeys = 1000, ContinuationToken } = command.input
|
||||||
|
|
||||||
|
console.log(`ListObjectsV2Command for bucket: ${Bucket}, prefix: ${Prefix || 'none'}`)
|
||||||
|
|
||||||
|
if (!mockS3Storage.has(Bucket)) {
|
||||||
|
console.log(`Bucket ${Bucket} not found, returning empty result`)
|
||||||
|
// Return empty result instead of rejecting
|
||||||
|
return Promise.resolve({
|
||||||
|
Contents: [],
|
||||||
|
IsTruncated: false,
|
||||||
|
KeyCount: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = mockS3Storage.get(Bucket)!
|
||||||
|
|
||||||
|
// Debug: Log all objects in the bucket
|
||||||
|
console.log(`All objects in bucket ${Bucket} before filtering:`)
|
||||||
|
for (const [key, obj] of bucket.objects.entries()) {
|
||||||
|
console.log(`- ${key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter objects by prefix if provided
|
||||||
|
console.log(`[MOCK S3] Filtering objects by prefix: "${Prefix || 'none'}"`)
|
||||||
|
console.log(`[MOCK S3] All keys in bucket before filtering:`)
|
||||||
|
for (const key of bucket.objects.keys()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredObjects = Array.from(bucket.objects.values()).filter(obj => {
|
||||||
|
if (!Prefix) return true
|
||||||
|
const matches = obj.key.startsWith(Prefix)
|
||||||
|
console.log(`[MOCK S3] Key: ${obj.key}, Matches prefix "${Prefix}": ${matches}`)
|
||||||
|
return matches
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${filteredObjects.length} objects with prefix: ${Prefix || 'none'}`)
|
||||||
|
|
||||||
|
// Debug: Log filtered objects
|
||||||
|
console.log(`Filtered objects:`)
|
||||||
|
for (const obj of filteredObjects) {
|
||||||
|
console.log(`- ${obj.key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
|
||||||
|
// Ensure each object has a valid body
|
||||||
|
try {
|
||||||
|
if (obj.contentType === 'application/json') {
|
||||||
|
const parsedBody = JSON.parse(obj.body)
|
||||||
|
console.log(`Parsed JSON body for ${obj.key}:`, parsedBody)
|
||||||
|
|
||||||
|
// If this is a noun or verb, ensure it has an id property
|
||||||
|
if (obj.key.includes('/nouns/') || obj.key.includes('/verbs/')) {
|
||||||
|
if (!parsedBody.id) {
|
||||||
|
console.error(`Warning: Object ${obj.key} does not have an id property`)
|
||||||
|
// Add id property based on the key name
|
||||||
|
const id = obj.key.split('/').pop()?.replace('.json', '') || 'unknown'
|
||||||
|
parsedBody.id = id
|
||||||
|
console.log(`Added id property: ${id}`)
|
||||||
|
obj.body = JSON.stringify(parsedBody)
|
||||||
|
obj.contentLength = obj.body.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing JSON body for ${obj.key}:`, error)
|
||||||
|
// Continue with the original body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const startIndex = ContinuationToken ? parseInt(ContinuationToken, 10) : 0
|
||||||
|
const endIndex = Math.min(startIndex + MaxKeys, filteredObjects.length)
|
||||||
|
const objects = filteredObjects.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
// Check if there are more objects
|
||||||
|
const isTruncated = endIndex < filteredObjects.length
|
||||||
|
const nextContinuationToken = isTruncated ? endIndex.toString() : undefined
|
||||||
|
|
||||||
|
// Map objects to the expected format
|
||||||
|
const contents = objects.map(obj => ({
|
||||||
|
Key: obj.key,
|
||||||
|
LastModified: obj.lastModified,
|
||||||
|
Size: obj.contentLength || obj.body.length, // Ensure Size is always set
|
||||||
|
ETag: `"${Math.random().toString(36).substring(2, 15)}"`
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log(`Returning ${contents.length} objects in response`)
|
||||||
|
|
||||||
|
// Debug: Log the contents being returned
|
||||||
|
if (contents.length > 0) {
|
||||||
|
console.log(`Contents being returned:`)
|
||||||
|
for (const obj of contents) {
|
||||||
|
console.log(`- ${obj.Key}, Size: ${obj.Size}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return Contents array, even if empty
|
||||||
|
return Promise.resolve({
|
||||||
|
Contents: contents,
|
||||||
|
IsTruncated: isTruncated,
|
||||||
|
NextContinuationToken: nextContinuationToken,
|
||||||
|
KeyCount: objects.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup S3 mock environment
|
||||||
|
*/
|
||||||
|
export function setupS3Mock() {
|
||||||
|
console.log('Setting up S3 mock environment')
|
||||||
|
|
||||||
|
// Clear the mock S3 storage
|
||||||
|
mockS3Storage.clear()
|
||||||
|
|
||||||
|
// Create mock S3 client with enhanced logging
|
||||||
|
const mockS3Client = {
|
||||||
|
send: async (command: any) => {
|
||||||
|
console.log(`[MOCK S3] Received command: ${command.constructor.name}`)
|
||||||
|
console.log(`[MOCK S3] Command input:`, command.input)
|
||||||
|
|
||||||
|
// Log the current state of the mock storage before processing the command
|
||||||
|
console.log(`[MOCK S3] Current storage state before command:`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log(`[MOCK S3] Objects in bucket ${bucketName}:`)
|
||||||
|
for (const [key, obj] of bucket.objects.entries()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the command using the original implementation
|
||||||
|
const result = await createMockS3Client().send(command)
|
||||||
|
|
||||||
|
// Log the result and the state of the mock storage after processing the command
|
||||||
|
console.log(`[MOCK S3] Command result:`, result)
|
||||||
|
console.log(`[MOCK S3] Storage state after command:`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log(`[MOCK S3] Objects in bucket ${bucketName}:`)
|
||||||
|
for (const [key, obj] of bucket.objects.entries()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}: ${obj.body.substring(0, 30)}...`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test bucket to ensure it exists
|
||||||
|
const testBucket = 'test-bucket'
|
||||||
|
if (!mockS3Storage.has(testBucket)) {
|
||||||
|
console.log(`Creating test bucket: ${testBucket}`)
|
||||||
|
mockS3Storage.set(testBucket, {
|
||||||
|
name: testBucket,
|
||||||
|
objects: new Map()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('S3 mock environment setup complete')
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockS3Client,
|
||||||
|
mockS3Storage,
|
||||||
|
reset: () => {
|
||||||
|
console.log('[MOCK S3] Resetting S3 mock storage')
|
||||||
|
|
||||||
|
// Log the state of the mock storage before reset
|
||||||
|
console.log('[MOCK S3] Mock storage before reset:')
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
if (bucket.objects.size > 0) {
|
||||||
|
console.log('[MOCK S3] Objects in bucket:')
|
||||||
|
for (const key of bucket.objects.keys()) {
|
||||||
|
console.log(`[MOCK S3] - ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the mock S3 storage completely
|
||||||
|
mockS3Storage.clear()
|
||||||
|
|
||||||
|
// Re-create the test bucket with an empty objects map
|
||||||
|
console.log(`[MOCK S3] Re-creating test bucket: ${testBucket}`)
|
||||||
|
mockS3Storage.set(testBucket, {
|
||||||
|
name: testBucket,
|
||||||
|
objects: new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the state of the mock storage after reset
|
||||||
|
console.log(`[MOCK S3] Mock storage after reset: ${mockS3Storage.size} buckets`)
|
||||||
|
for (const [bucketName, bucket] of mockS3Storage.entries()) {
|
||||||
|
console.log(`[MOCK S3] Bucket ${bucketName}: ${bucket.objects.size} objects`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the mock client is using the latest storage state
|
||||||
|
console.log('[MOCK S3] Ensuring mock client is using the latest storage state')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup S3 mock environment
|
||||||
|
*/
|
||||||
|
export function cleanupS3Mock() {
|
||||||
|
console.log('Cleaning up S3 mock environment')
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
|
||||||
|
// Clear the mock S3 storage
|
||||||
|
mockS3Storage.clear()
|
||||||
|
|
||||||
|
console.log('S3 mock environment cleanup complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mock S3 command classes
|
||||||
|
*/
|
||||||
|
export const S3Commands = {
|
||||||
|
CreateBucketCommand: class CreateBucketCommand {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
},
|
||||||
|
HeadBucketCommand: class HeadBucketCommand {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PutObjectCommand: class PutObjectCommand {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GetObjectCommand: class GetObjectCommand {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DeleteObjectCommand: class DeleteObjectCommand {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ListObjectsV2Command: class ListObjectsV2Command {
|
||||||
|
input: any
|
||||||
|
constructor(input: any) {
|
||||||
|
this.input = input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
tests/multi-environment.test.ts
Normal file
258
tests/multi-environment.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Multi-Environment Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite verifies that Brainy works correctly across different environments:
|
||||||
|
* 1. Node.js
|
||||||
|
* 2. Browser
|
||||||
|
* 3. Web Worker
|
||||||
|
* 4. Worker Threads
|
||||||
|
*
|
||||||
|
* These tests ensure consistent behavior regardless of the runtime environment.
|
||||||
|
* Some tests are conditionally executed based on the current environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { BrainyData, createStorage, environment } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Multi-Environment Tests', () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test BrainyData instance with memory storage for faster tests
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure a clean test environment
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up after each test
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Environment Detection', () => {
|
||||||
|
it('should correctly detect the current environment', () => {
|
||||||
|
// Check that environment detection functions exist
|
||||||
|
expect(typeof environment.isNode).toBe('boolean')
|
||||||
|
expect(typeof environment.isBrowser).toBe('boolean')
|
||||||
|
|
||||||
|
// In Node.js test environment, isNode should be true and isBrowser should be false
|
||||||
|
// In browser test environment (jsdom), isBrowser might be true
|
||||||
|
if (typeof process !== 'undefined' && process.versions && process.versions.node) {
|
||||||
|
expect(environment.isNode).toBe(true)
|
||||||
|
expect(environment.isBrowser).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect threading availability', async () => {
|
||||||
|
// Check that threading detection functions exist
|
||||||
|
expect(typeof environment.isThreadingAvailable).toBe('boolean')
|
||||||
|
|
||||||
|
// The actual value depends on the environment
|
||||||
|
const threadingAvailable = await environment.isThreadingAvailableAsync()
|
||||||
|
expect(typeof threadingAvailable).toBe('boolean')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Node.js Environment', () => {
|
||||||
|
// Only run these tests in Node.js environment
|
||||||
|
if (!environment.isNode) {
|
||||||
|
it.skip('Node.js specific tests skipped in non-Node environment', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should use FileSystem storage by default in Node.js', async () => {
|
||||||
|
// Create storage with auto detection
|
||||||
|
const storage = await createStorage({ type: 'auto' })
|
||||||
|
|
||||||
|
// Get storage status
|
||||||
|
const status = await storage.getStorageStatus()
|
||||||
|
expect(status.type).toBe('filesystem')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Worker Threads if available', async () => {
|
||||||
|
// This is a basic check - actual worker thread testing would require more setup
|
||||||
|
const workerThreadsAvailable = await environment.areWorkerThreadsAvailable()
|
||||||
|
|
||||||
|
// Just verify the function returns a boolean
|
||||||
|
expect(typeof workerThreadsAvailable).toBe('boolean')
|
||||||
|
|
||||||
|
// If worker threads are available, we could test them more thoroughly
|
||||||
|
if (workerThreadsAvailable) {
|
||||||
|
// This would require setting up actual worker threads
|
||||||
|
// which is beyond the scope of this basic test
|
||||||
|
expect(true).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Browser Environment', () => {
|
||||||
|
// Mock browser environment if needed
|
||||||
|
let originalWindow: any
|
||||||
|
let originalDocument: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Save original globals
|
||||||
|
originalWindow = global.window
|
||||||
|
originalDocument = global.document
|
||||||
|
|
||||||
|
// Mock browser environment if not already in one
|
||||||
|
if (!environment.isBrowser) {
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.window = { location: { href: 'http://localhost/' } }
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.document = { createElement: vi.fn() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original globals
|
||||||
|
global.window = originalWindow
|
||||||
|
global.document = originalDocument
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect browser environment correctly', () => {
|
||||||
|
// With our mocks in place, isBrowser should be true
|
||||||
|
expect(environment.isBrowser).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer OPFS storage in browser if available', async () => {
|
||||||
|
// Mock OPFS availability
|
||||||
|
if (!global.navigator) {
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.navigator = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.navigator.storage) {
|
||||||
|
global.navigator.storage = {} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock the storage.getDirectory method to simulate OPFS availability
|
||||||
|
// Create a more complete mock of the directory handle
|
||||||
|
const mockDirectoryHandle = {
|
||||||
|
getDirectoryHandle: vi.fn().mockImplementation((name, options) => {
|
||||||
|
return Promise.resolve({
|
||||||
|
kind: 'directory',
|
||||||
|
name,
|
||||||
|
getDirectoryHandle: vi.fn().mockResolvedValue({
|
||||||
|
kind: 'directory',
|
||||||
|
getFileHandle: vi.fn().mockResolvedValue({
|
||||||
|
kind: 'file',
|
||||||
|
getFile: vi.fn().mockResolvedValue({
|
||||||
|
text: vi.fn().mockResolvedValue('{}')
|
||||||
|
}),
|
||||||
|
createWritable: vi.fn().mockResolvedValue({
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
entries: vi.fn().mockImplementation(function* () {
|
||||||
|
// Empty generator
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
getFileHandle: vi.fn().mockResolvedValue({
|
||||||
|
kind: 'file',
|
||||||
|
getFile: vi.fn().mockResolvedValue({
|
||||||
|
text: vi.fn().mockResolvedValue('{}')
|
||||||
|
}),
|
||||||
|
createWritable: vi.fn().mockResolvedValue({
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
entries: vi.fn().mockImplementation(function* () {
|
||||||
|
// Empty generator
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
entries: vi.fn().mockImplementation(function* () {
|
||||||
|
// Empty generator
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
global.navigator.storage.getDirectory = vi.fn().mockResolvedValue(mockDirectoryHandle)
|
||||||
|
|
||||||
|
// Create storage with auto detection
|
||||||
|
const storage = await createStorage({ type: 'auto' })
|
||||||
|
|
||||||
|
// Get storage status - this might still be memory if our mocks aren't complete
|
||||||
|
const status = await storage.getStorageStatus()
|
||||||
|
|
||||||
|
// In a real browser with OPFS, this would be 'opfs'
|
||||||
|
// In our mocked environment, it might be 'memory' due to incomplete mocking
|
||||||
|
expect(['opfs', 'memory']).toContain(status.type)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Web Worker Environment', () => {
|
||||||
|
// Mock Web Worker environment
|
||||||
|
let originalSelf: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Save original self
|
||||||
|
originalSelf = global.self
|
||||||
|
|
||||||
|
// Mock Web Worker environment
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.self = {
|
||||||
|
constructor: { name: 'DedicatedWorkerGlobalScope' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original self
|
||||||
|
global.self = originalSelf
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect Web Worker environment correctly', () => {
|
||||||
|
// With our mocks in place, isWebWorker should be true
|
||||||
|
expect(environment.isWebWorker()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cross-Environment Data Compatibility', () => {
|
||||||
|
it('should create compatible vector formats across environments', async () => {
|
||||||
|
// Add data
|
||||||
|
const id = await brainyInstance.add('cross-environment test')
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Get the item with its vector
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.vector).toBeDefined()
|
||||||
|
|
||||||
|
// Vectors should be standard JavaScript arrays regardless of environment
|
||||||
|
expect(Array.isArray(item.vector)).toBe(true)
|
||||||
|
|
||||||
|
// Create a backup (which should be environment-independent)
|
||||||
|
const backup = await brainyInstance.backup()
|
||||||
|
expect(backup).toBeDefined()
|
||||||
|
|
||||||
|
// The backup should be a standard JSON object
|
||||||
|
expect(typeof backup).toBe('object')
|
||||||
|
|
||||||
|
// Clear the database
|
||||||
|
await brainyInstance.clear()
|
||||||
|
|
||||||
|
// Restore from backup
|
||||||
|
await brainyInstance.restore(backup)
|
||||||
|
|
||||||
|
// Verify the item was restored correctly
|
||||||
|
const restoredItem = await brainyInstance.get(id)
|
||||||
|
expect(restoredItem).toBeDefined()
|
||||||
|
// In the current implementation, vector might not be preserved during backup/restore
|
||||||
|
// Skip vector checks as they're not critical for cross-environment compatibility
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
257
tests/opfs-storage.test.ts
Normal file
257
tests/opfs-storage.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* OPFS Storage Tests
|
||||||
|
* Tests for the OPFS storage adapter using a simulated OPFS environment
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setupOPFSMock, cleanupOPFSMock } from './mocks/opfs-mock'
|
||||||
|
import { Vector } from '../src/coreTypes'
|
||||||
|
|
||||||
|
describe('OPFSStorage', () => {
|
||||||
|
// Import modules inside tests to avoid issues with dynamic imports
|
||||||
|
let OPFSStorage: any
|
||||||
|
let opfsMock: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup OPFS mock environment
|
||||||
|
opfsMock = setupOPFSMock()
|
||||||
|
|
||||||
|
// Import storage factory
|
||||||
|
const storageFactory = await import('../src/storage/storageFactory.js')
|
||||||
|
OPFSStorage = storageFactory.OPFSStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up OPFS mock environment
|
||||||
|
cleanupOPFSMock()
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect OPFS availability correctly', () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// With our mocks in place, OPFS should be available
|
||||||
|
expect(opfsStorage.isOPFSAvailable()).toBe(true)
|
||||||
|
|
||||||
|
// Now remove the getDirectory method to simulate OPFS not being available
|
||||||
|
delete global.navigator.storage.getDirectory
|
||||||
|
|
||||||
|
// Create a new instance with the modified environment
|
||||||
|
const opfsStorage2 = new OPFSStorage()
|
||||||
|
expect(opfsStorage2.isOPFSAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize and perform basic operations with OPFS storage', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await opfsStorage.init()
|
||||||
|
|
||||||
|
// Test basic metadata operations
|
||||||
|
const testMetadata = { test: 'data', value: 123 }
|
||||||
|
await opfsStorage.saveMetadata('test-key', testMetadata)
|
||||||
|
|
||||||
|
const retrievedMetadata = await opfsStorage.getMetadata('test-key')
|
||||||
|
expect(retrievedMetadata).toEqual(testMetadata)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await opfsStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle noun operations correctly', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await opfsStorage.init()
|
||||||
|
|
||||||
|
// Create test noun
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const testNoun = {
|
||||||
|
id: 'test-noun-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map([
|
||||||
|
[0, new Set(['test-noun-2', 'test-noun-3'])]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the noun
|
||||||
|
await opfsStorage.saveNoun(testNoun)
|
||||||
|
|
||||||
|
// Retrieve the noun
|
||||||
|
const retrievedNoun = await opfsStorage.getNoun('test-noun-1')
|
||||||
|
|
||||||
|
// Verify the noun was saved and retrieved correctly
|
||||||
|
expect(retrievedNoun).toBeDefined()
|
||||||
|
expect(retrievedNoun?.id).toBe('test-noun-1')
|
||||||
|
expect(retrievedNoun?.vector).toEqual(testVector)
|
||||||
|
|
||||||
|
// Verify connections were saved correctly
|
||||||
|
// Note: connections are stored as a Map in memory but might be serialized differently
|
||||||
|
expect(retrievedNoun?.connections).toBeDefined()
|
||||||
|
expect(retrievedNoun?.connections.get(0)).toBeDefined()
|
||||||
|
expect(retrievedNoun?.connections.get(0)?.has('test-noun-2')).toBe(true)
|
||||||
|
expect(retrievedNoun?.connections.get(0)?.has('test-noun-3')).toBe(true)
|
||||||
|
|
||||||
|
// Check if the noun is actually stored first
|
||||||
|
console.log('DEBUG: Checking if noun exists after save')
|
||||||
|
const storedNoun = await opfsStorage.getNoun('test-noun-1')
|
||||||
|
console.log('DEBUG: storedNoun:', storedNoun ? 'EXISTS' : 'NOT FOUND')
|
||||||
|
|
||||||
|
// Test getNouns with pagination
|
||||||
|
console.log('DEBUG: About to test getNouns')
|
||||||
|
const nounsResult = await opfsStorage.getNouns({ pagination: { limit: 10 } })
|
||||||
|
console.log('DEBUG: getNouns result:', nounsResult.items.length)
|
||||||
|
|
||||||
|
expect(nounsResult.items.length).toBe(1)
|
||||||
|
expect(nounsResult.items[0].id).toBe('test-noun-1')
|
||||||
|
|
||||||
|
// Test deleteNoun
|
||||||
|
await opfsStorage.deleteNoun('test-noun-1')
|
||||||
|
const deletedNoun = await opfsStorage.getNoun('test-noun-1')
|
||||||
|
expect(deletedNoun).toBeNull()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await opfsStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle verb operations correctly', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await opfsStorage.init()
|
||||||
|
|
||||||
|
// Create test verb
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const timestamp = {
|
||||||
|
seconds: Math.floor(Date.now() / 1000),
|
||||||
|
nanoseconds: (Date.now() % 1000) * 1000000
|
||||||
|
}
|
||||||
|
const testVerb = {
|
||||||
|
id: 'test-verb-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map(),
|
||||||
|
source: 'source-noun-1',
|
||||||
|
target: 'target-noun-1',
|
||||||
|
verb: 'test-relation',
|
||||||
|
weight: 0.75,
|
||||||
|
metadata: { description: 'Test relation' },
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
createdBy: {
|
||||||
|
augmentation: 'test-service',
|
||||||
|
version: '1.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the verb
|
||||||
|
await opfsStorage.saveVerb(testVerb)
|
||||||
|
|
||||||
|
// Retrieve the verb
|
||||||
|
const retrievedVerb = await opfsStorage.getVerb('test-verb-1')
|
||||||
|
|
||||||
|
// Verify the verb was saved and retrieved correctly
|
||||||
|
expect(retrievedVerb).toBeDefined()
|
||||||
|
expect(retrievedVerb?.id).toBe('test-verb-1')
|
||||||
|
expect(retrievedVerb?.vector).toEqual(testVector)
|
||||||
|
expect(retrievedVerb?.source).toBe('source-noun-1')
|
||||||
|
expect(retrievedVerb?.target).toBe('target-noun-1')
|
||||||
|
expect(retrievedVerb?.verb).toBe('test-relation')
|
||||||
|
expect(retrievedVerb?.weight).toBe(0.75)
|
||||||
|
expect(retrievedVerb?.metadata).toEqual({ description: 'Test relation' })
|
||||||
|
expect(retrievedVerb?.createdAt).toEqual(timestamp)
|
||||||
|
expect(retrievedVerb?.updatedAt).toEqual(timestamp)
|
||||||
|
expect(retrievedVerb?.createdBy).toEqual({
|
||||||
|
augmentation: 'test-service',
|
||||||
|
version: '1.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test getVerbs with pagination
|
||||||
|
const verbsResult = await opfsStorage.getVerbs({ pagination: { limit: 10 } })
|
||||||
|
expect(verbsResult.items.length).toBe(1)
|
||||||
|
expect(verbsResult.items[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsBySource
|
||||||
|
const verbsBySource = await opfsStorage.getVerbsBySource('source-noun-1')
|
||||||
|
expect(verbsBySource.length).toBe(1)
|
||||||
|
expect(verbsBySource[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsByTarget
|
||||||
|
const verbsByTarget = await opfsStorage.getVerbsByTarget('target-noun-1')
|
||||||
|
expect(verbsByTarget.length).toBe(1)
|
||||||
|
expect(verbsByTarget[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsByType
|
||||||
|
const verbsByType = await opfsStorage.getVerbsByType('test-relation')
|
||||||
|
expect(verbsByType.length).toBe(1)
|
||||||
|
expect(verbsByType[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test deleteVerb
|
||||||
|
await opfsStorage.deleteVerb('test-verb-1')
|
||||||
|
const deletedVerb = await opfsStorage.getVerb('test-verb-1')
|
||||||
|
expect(deletedVerb).toBeNull()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await opfsStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle storage status correctly', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await opfsStorage.init()
|
||||||
|
|
||||||
|
// Add some data to the storage
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const testNoun = {
|
||||||
|
id: 'test-noun-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map([
|
||||||
|
[0, new Set(['test-noun-2', 'test-noun-3'])]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
await opfsStorage.saveNoun(testNoun)
|
||||||
|
await opfsStorage.saveMetadata('test-key', { test: 'data', value: 123 })
|
||||||
|
|
||||||
|
// Get storage status
|
||||||
|
const status = await opfsStorage.getStorageStatus()
|
||||||
|
|
||||||
|
// Verify status
|
||||||
|
expect(status.type).toBe('opfs')
|
||||||
|
expect(status.used).toBeGreaterThan(0)
|
||||||
|
expect(status.quota).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await opfsStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle persistence correctly', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await opfsStorage.init()
|
||||||
|
|
||||||
|
// Test persistence methods
|
||||||
|
const isPersisted = await opfsStorage.isPersistent()
|
||||||
|
expect(isPersisted).toBe(true)
|
||||||
|
|
||||||
|
// Get the current persistence state
|
||||||
|
const initialPersistence = await opfsStorage.isPersistent()
|
||||||
|
expect(initialPersistence).toBe(true)
|
||||||
|
|
||||||
|
// Request persistence (should return true with our mock)
|
||||||
|
const persistResult = await opfsStorage.requestPersistentStorage()
|
||||||
|
expect(persistResult).toBe(true)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await opfsStorage.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
108
tests/package-install.test.ts
Normal file
108
tests/package-install.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Package Installation Test
|
||||||
|
*
|
||||||
|
* This test simulates installing the @soulcraft/brainy package in a clean environment
|
||||||
|
* to verify that no --legacy-peer-deps warnings occur and the package installs cleanly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { mkdtemp, rm, writeFile, readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
describe('Package Installation', () => {
|
||||||
|
let tempDir: string
|
||||||
|
let packagePath: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'brainy-install-test-'))
|
||||||
|
|
||||||
|
// Pack the current package
|
||||||
|
const { stdout } = await execAsync('npm pack')
|
||||||
|
// The npm pack output includes the filename on the last line
|
||||||
|
const lines = stdout.trim().split('\n')
|
||||||
|
const packageFile = lines[lines.length - 1]
|
||||||
|
packagePath = join(process.cwd(), packageFile)
|
||||||
|
console.log('Created package:', packagePath)
|
||||||
|
}, 120000) // 2 minute timeout for packing
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up
|
||||||
|
try {
|
||||||
|
await rm(tempDir, { recursive: true, force: true })
|
||||||
|
await rm(packagePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cleanup error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should install without peer dependency warnings', async () => {
|
||||||
|
// Create a minimal package.json
|
||||||
|
const testPackageJson = {
|
||||||
|
name: 'test-brainy-install',
|
||||||
|
version: '1.0.0',
|
||||||
|
type: 'module',
|
||||||
|
dependencies: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
join(tempDir, 'package.json'),
|
||||||
|
JSON.stringify(testPackageJson, null, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Install the package and capture output
|
||||||
|
// Use --ignore-scripts to skip the prepare script during install test
|
||||||
|
let installOutput = ''
|
||||||
|
let installError = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(
|
||||||
|
`npm install ${packagePath} --loglevel=warn --ignore-scripts`,
|
||||||
|
{ cwd: tempDir }
|
||||||
|
)
|
||||||
|
installOutput = stdout
|
||||||
|
installError = stderr
|
||||||
|
} catch (error: any) {
|
||||||
|
installOutput = error.stdout || ''
|
||||||
|
installError = error.stderr || ''
|
||||||
|
|
||||||
|
// If installation actually failed (not just warnings), throw
|
||||||
|
if (error.code !== 0 && !installError.includes('npm WARN')) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Install output:', installOutput)
|
||||||
|
console.log('Install warnings/errors:', installError)
|
||||||
|
|
||||||
|
// Verify no legacy peer deps warning
|
||||||
|
expect(installError).not.toContain('--legacy-peer-deps')
|
||||||
|
expect(installError).not.toContain('conflicting peer dependency')
|
||||||
|
expect(installError).not.toContain('Could not resolve dependency')
|
||||||
|
|
||||||
|
// Verify the warning about optional @soulcraft/brainy-models is acceptable
|
||||||
|
// This is expected and okay since it's marked as optional
|
||||||
|
if (installError.includes('@soulcraft/brainy-models')) {
|
||||||
|
expect(installError).toContain('optional')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify package was actually installed
|
||||||
|
const installedPackageJson = await readFile(
|
||||||
|
join(tempDir, 'package.json'),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
const installedPackage = JSON.parse(installedPackageJson)
|
||||||
|
expect(installedPackage.dependencies).toHaveProperty('@soulcraft/brainy')
|
||||||
|
}, 120000) // 2 minute timeout
|
||||||
|
|
||||||
|
it.skip('should allow basic usage after installation', async () => {
|
||||||
|
// Skip this test as it requires the package to be fully built
|
||||||
|
// The first test is sufficient to verify no peer dependency warnings
|
||||||
|
console.log('Skipping usage test - requires full build')
|
||||||
|
})
|
||||||
|
})
|
||||||
176
tests/package-size-breakdown.test.ts
Normal file
176
tests/package-size-breakdown.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* Package Size Breakdown Test
|
||||||
|
* Analyzes the files that would be included in the npm package and reports their sizes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Get the project root directory
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
// Function to get the size of a file in MB
|
||||||
|
function getFileSizeInMB(filePath: string): number {
|
||||||
|
const stats = fs.statSync(filePath)
|
||||||
|
return stats.size / (1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if a file should be included in the package
|
||||||
|
function shouldIncludeFile(
|
||||||
|
filePath: string,
|
||||||
|
npmignorePatterns: RegExp[],
|
||||||
|
includePatterns: RegExp[]
|
||||||
|
): boolean {
|
||||||
|
const relativePath = path.relative(projectRoot, filePath)
|
||||||
|
|
||||||
|
// Check if the file matches any npmignore pattern
|
||||||
|
for (const pattern of npmignorePatterns) {
|
||||||
|
if (pattern.test(relativePath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have explicit include patterns, check if the file matches any
|
||||||
|
if (includePatterns.length > 0) {
|
||||||
|
for (const pattern of includePatterns) {
|
||||||
|
if (pattern.test(relativePath)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse .npmignore file
|
||||||
|
function parseNpmignore(): RegExp[] {
|
||||||
|
const patterns: RegExp[] = []
|
||||||
|
const npmignorePath = path.join(projectRoot, '.npmignore')
|
||||||
|
|
||||||
|
if (fs.existsSync(npmignorePath)) {
|
||||||
|
const content = fs.readFileSync(npmignorePath, 'utf8')
|
||||||
|
const lines = content.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||||
|
// Convert glob pattern to regex
|
||||||
|
let pattern = trimmedLine
|
||||||
|
.replace(/\./g, '\\.')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
.replace(/\?/g, '.')
|
||||||
|
|
||||||
|
// Handle directory patterns
|
||||||
|
if (pattern.endsWith('/')) {
|
||||||
|
pattern = `${pattern}.*`
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns.push(new RegExp(`^${pattern}$`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse package.json files array
|
||||||
|
function parsePackageFiles(): RegExp[] {
|
||||||
|
const patterns: RegExp[] = []
|
||||||
|
const packageJsonPath = path.join(projectRoot, 'package.json')
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||||
|
|
||||||
|
if (packageJson.files && Array.isArray(packageJson.files)) {
|
||||||
|
for (const pattern of packageJson.files) {
|
||||||
|
// Convert glob pattern to regex
|
||||||
|
let regexPattern = pattern
|
||||||
|
.replace(/\./g, '\\.')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
.replace(/\?/g, '.')
|
||||||
|
|
||||||
|
// Handle directory patterns
|
||||||
|
if (regexPattern.endsWith('/')) {
|
||||||
|
regexPattern = `${regexPattern}.*`
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns.push(new RegExp(`^${regexPattern}$`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the total size of files that would be included in the package
|
||||||
|
function calculatePackageSize(): {
|
||||||
|
totalSize: number,
|
||||||
|
includedFiles: { path: string, size: number }[]
|
||||||
|
} {
|
||||||
|
const npmignorePatterns = parseNpmignore()
|
||||||
|
const includePatterns = parsePackageFiles()
|
||||||
|
|
||||||
|
let totalSize = 0
|
||||||
|
const includedFiles: { path: string, size: number }[] = []
|
||||||
|
|
||||||
|
function processDirectory(dirPath: string) {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
processDirectory(fullPath)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
if (shouldIncludeFile(fullPath, npmignorePatterns, includePatterns)) {
|
||||||
|
const sizeInMB = getFileSizeInMB(fullPath)
|
||||||
|
totalSize += sizeInMB
|
||||||
|
includedFiles.push({ path: fullPath, size: sizeInMB })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processDirectory(projectRoot)
|
||||||
|
|
||||||
|
// Sort files by size (largest first)
|
||||||
|
includedFiles.sort((a, b) => b.size - a.size)
|
||||||
|
|
||||||
|
return { totalSize, includedFiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Package Size Breakdown', () => {
|
||||||
|
it('should report the estimated package size and largest files', () => {
|
||||||
|
const { totalSize, includedFiles } = calculatePackageSize()
|
||||||
|
|
||||||
|
console.log('Estimated package size: ' + totalSize.toFixed(2) + ' MB')
|
||||||
|
console.log('\nLargest files:')
|
||||||
|
for (let i = 0; i < Math.min(10, includedFiles.length); i++) {
|
||||||
|
console.log(
|
||||||
|
`${includedFiles[i].path}: ${includedFiles[i].size.toFixed(2)} MB`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic sanity check
|
||||||
|
expect(totalSize).toBeGreaterThan(0)
|
||||||
|
expect(includedFiles.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should identify files that contribute significantly to package size', () => {
|
||||||
|
const { includedFiles } = calculatePackageSize()
|
||||||
|
|
||||||
|
// Find files larger than 1MB
|
||||||
|
const largeFiles = includedFiles.filter(file => file.size > 1)
|
||||||
|
|
||||||
|
if (largeFiles.length > 0) {
|
||||||
|
console.log('\nFiles larger than 1MB:')
|
||||||
|
largeFiles.forEach(file => {
|
||||||
|
console.log(`${file.path}: ${file.size.toFixed(2)} MB`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is not a failure condition, just informational
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
146
tests/package-size-limit.test.ts
Normal file
146
tests/package-size-limit.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* Package Size Limit Tests
|
||||||
|
* Tests the predicted npm package size to ensure it stays within acceptable limits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {execSync} from 'child_process'
|
||||||
|
|
||||||
|
const CURRENT_UNPACKED_SIZE_MB = 2.5
|
||||||
|
const CURRENT_PACKED_SIZE_MB = 0.65
|
||||||
|
const ALLOWED_SIZE_INCREASE_PERCENTAGE = 10 // 10% increase threshold
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses npm pack --dry-run output to extract package size information
|
||||||
|
*/
|
||||||
|
function parseNpmPackOutput(output: string): {
|
||||||
|
packedSizeMB: number
|
||||||
|
unpackedSizeMB: number
|
||||||
|
totalFiles: number
|
||||||
|
} {
|
||||||
|
const packageSizeMatch = output.match(
|
||||||
|
/npm notice package size:\s*([\d.]+)\s*([KMGTkmgt]?B)/
|
||||||
|
)
|
||||||
|
const unpackedSizeMatch = output.match(
|
||||||
|
/npm notice unpacked size:\s*([\d.]+)\s*([KMGTkmgt]?B)/
|
||||||
|
)
|
||||||
|
const totalFilesMatch = output.match(/npm notice total files:\s*(\d+)/)
|
||||||
|
|
||||||
|
const convertToMB = (size: number, unit: string): number => {
|
||||||
|
switch (unit.toUpperCase()) {
|
||||||
|
case 'B':
|
||||||
|
return size / (1024 * 1024)
|
||||||
|
case 'KB':
|
||||||
|
return size / 1024
|
||||||
|
case 'MB':
|
||||||
|
return size
|
||||||
|
case 'GB':
|
||||||
|
return size * 1024
|
||||||
|
default:
|
||||||
|
return size / (1024 * 1024) // assume bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packedSizeMB = packageSizeMatch
|
||||||
|
? convertToMB(parseFloat(packageSizeMatch[1]), packageSizeMatch[2])
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const unpackedSizeMB = unpackedSizeMatch
|
||||||
|
? convertToMB(parseFloat(unpackedSizeMatch[1]), unpackedSizeMatch[2])
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const totalFiles = totalFilesMatch ? parseInt(totalFilesMatch[1], 10) : 0
|
||||||
|
|
||||||
|
return {packedSizeMB, unpackedSizeMB, totalFiles}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached npm package size result to avoid multiple expensive npm pack calls
|
||||||
|
*/
|
||||||
|
let cachedPackageSize: {
|
||||||
|
packedSizeMB: number
|
||||||
|
unpackedSizeMB: number
|
||||||
|
totalFiles: number
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the predicted npm package size using npm pack --dry-run
|
||||||
|
* Results are cached to avoid multiple expensive executions
|
||||||
|
*/
|
||||||
|
async function getNpmPackageSize(): Promise<{
|
||||||
|
packedSizeMB: number
|
||||||
|
unpackedSizeMB: number
|
||||||
|
totalFiles: number
|
||||||
|
}> {
|
||||||
|
// Return cached result if available
|
||||||
|
if (cachedPackageSize) {
|
||||||
|
return cachedPackageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use 2>&1 to capture both stdout and stderr in one command
|
||||||
|
const output = execSync('npm pack --dry-run 2>&1', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
timeout: 45000 // 45 second timeout to prevent hanging
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = parseNpmPackOutput(output)
|
||||||
|
|
||||||
|
// Cache the result for subsequent calls
|
||||||
|
cachedPackageSize = result
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get npm package size: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Package Size Limits', () => {
|
||||||
|
it('should not exceed unpacked size threshold for npm package', async () => {
|
||||||
|
const {unpackedSizeMB} = await getNpmPackageSize()
|
||||||
|
const maxAllowedSize =
|
||||||
|
CURRENT_UNPACKED_SIZE_MB * (1 + ALLOWED_SIZE_INCREASE_PERCENTAGE / 100)
|
||||||
|
|
||||||
|
console.log(`Current unpacked package size: ${unpackedSizeMB.toFixed(2)}MB`)
|
||||||
|
console.log(`Maximum allowed unpacked size: ${maxAllowedSize.toFixed(2)}MB`)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
unpackedSizeMB,
|
||||||
|
`Unpacked package size (${unpackedSizeMB.toFixed(2)}MB) exceeds maximum allowed size (${maxAllowedSize.toFixed(2)}MB)`
|
||||||
|
).toBeLessThanOrEqual(maxAllowedSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not exceed packed size threshold for npm package', async () => {
|
||||||
|
const {packedSizeMB} = await getNpmPackageSize()
|
||||||
|
const maxAllowedSize =
|
||||||
|
CURRENT_PACKED_SIZE_MB * (1 + ALLOWED_SIZE_INCREASE_PERCENTAGE / 100)
|
||||||
|
|
||||||
|
console.log(`Current packed package size: ${packedSizeMB.toFixed(2)}MB`)
|
||||||
|
console.log(`Maximum allowed packed size: ${maxAllowedSize.toFixed(2)}MB`)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
packedSizeMB,
|
||||||
|
`Packed package size (${packedSizeMB.toFixed(2)}MB) exceeds maximum allowed size (${maxAllowedSize.toFixed(2)}MB)`
|
||||||
|
).toBeLessThanOrEqual(maxAllowedSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report package composition details', async () => {
|
||||||
|
const {packedSizeMB, unpackedSizeMB, totalFiles} =
|
||||||
|
await getNpmPackageSize()
|
||||||
|
|
||||||
|
console.log(`\nPackage composition:`)
|
||||||
|
console.log(`- Total files: ${totalFiles}`)
|
||||||
|
console.log(`- Packed size: ${packedSizeMB.toFixed(2)}MB`)
|
||||||
|
console.log(`- Unpacked size: ${unpackedSizeMB.toFixed(2)}MB`)
|
||||||
|
console.log(
|
||||||
|
`- Compression ratio: ${((1 - packedSizeMB / unpackedSizeMB) * 100).toFixed(1)}%`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Basic sanity checks
|
||||||
|
expect(totalFiles).toBeGreaterThan(0)
|
||||||
|
expect(packedSizeMB).toBeGreaterThan(0)
|
||||||
|
expect(unpackedSizeMB).toBeGreaterThan(0)
|
||||||
|
expect(packedSizeMB).toBeLessThan(unpackedSizeMB)
|
||||||
|
})
|
||||||
|
})
|
||||||
255
tests/pagination.test.ts
Normal file
255
tests/pagination.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* Tests for offset-based pagination in search results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { cleanupWorkerPools } from '../src/utils/index.js'
|
||||||
|
|
||||||
|
describe('Pagination with Offset', () => {
|
||||||
|
let db: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Initialize BrainyData with in-memory storage for testing
|
||||||
|
db = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
verbose: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await db.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.clear()
|
||||||
|
await cleanupWorkerPools()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Basic Offset Pagination', () => {
|
||||||
|
it('should return correct results with offset=0', async () => {
|
||||||
|
// Add test data
|
||||||
|
const testData = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const item = {
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`,
|
||||||
|
value: i
|
||||||
|
}
|
||||||
|
testData.push(item)
|
||||||
|
await db.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search without offset (default offset=0)
|
||||||
|
const results = await db.search('test document', 5)
|
||||||
|
expect(results.length).toBe(5)
|
||||||
|
|
||||||
|
// Results should be the top 5 most similar
|
||||||
|
const resultIds = results.map(r => r.metadata.id)
|
||||||
|
expect(resultIds.length).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip results with offset > 0', async () => {
|
||||||
|
// Add test data
|
||||||
|
const testData = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const item = {
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`,
|
||||||
|
value: i
|
||||||
|
}
|
||||||
|
testData.push(item)
|
||||||
|
await db.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first page (no offset)
|
||||||
|
const firstPage = await db.search('test document', 5)
|
||||||
|
expect(firstPage.length).toBe(5)
|
||||||
|
const firstPageIds = firstPage.map(r => r.metadata.id)
|
||||||
|
|
||||||
|
// Get second page (offset=5)
|
||||||
|
const secondPage = await db.search('test document', 5, { offset: 5 })
|
||||||
|
expect(secondPage.length).toBe(5)
|
||||||
|
const secondPageIds = secondPage.map(r => r.metadata.id)
|
||||||
|
|
||||||
|
// Ensure no overlap between pages
|
||||||
|
const overlap = firstPageIds.filter(id => secondPageIds.includes(id))
|
||||||
|
expect(overlap.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle offset beyond available results', async () => {
|
||||||
|
// Add limited test data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with offset beyond available results
|
||||||
|
const results = await db.search('test document', 5, { offset: 15 })
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return partial results when offset + k exceeds total', async () => {
|
||||||
|
// Add limited test data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with offset that allows only partial results
|
||||||
|
const results = await db.search('test document', 5, { offset: 7 })
|
||||||
|
expect(results.length).toBe(3) // Only 3 results available after offset 7
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Pagination with Filters', () => {
|
||||||
|
it.skip('should paginate with noun type filters', async () => {
|
||||||
|
// TODO: This test requires proper noun type support in the add method
|
||||||
|
// Currently skipped as noun types are not directly supported in the add method
|
||||||
|
// Add test data with different noun types
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `doc-${i}`,
|
||||||
|
text: `document ${i}`,
|
||||||
|
type: 'document'
|
||||||
|
}, undefined, 'document')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `note-${i}`,
|
||||||
|
text: `note ${i}`,
|
||||||
|
type: 'note'
|
||||||
|
}, undefined, 'note')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first page of documents
|
||||||
|
const firstPage = await db.search('document', 5, {
|
||||||
|
nounTypes: ['document']
|
||||||
|
})
|
||||||
|
expect(firstPage.length).toBe(5)
|
||||||
|
expect(firstPage.every(r => r.metadata.type === 'document')).toBe(true)
|
||||||
|
|
||||||
|
// Get second page of documents
|
||||||
|
const secondPage = await db.search('document', 5, {
|
||||||
|
nounTypes: ['document'],
|
||||||
|
offset: 5
|
||||||
|
})
|
||||||
|
expect(secondPage.length).toBe(5)
|
||||||
|
expect(secondPage.every(r => r.metadata.type === 'document')).toBe(true)
|
||||||
|
|
||||||
|
// Ensure pages are different
|
||||||
|
const firstIds = firstPage.map(r => r.metadata.id)
|
||||||
|
const secondIds = secondPage.map(r => r.metadata.id)
|
||||||
|
const overlap = firstIds.filter(id => secondIds.includes(id))
|
||||||
|
expect(overlap.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should paginate with service filters', async () => {
|
||||||
|
// Add test data with different services
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const metadata = {
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test item ${i}`,
|
||||||
|
createdBy: {
|
||||||
|
augmentation: i < 10 ? 'service-a' : 'service-b'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.add(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results for service-a
|
||||||
|
const page1 = await db.search('test item', 3, {
|
||||||
|
service: 'service-a'
|
||||||
|
})
|
||||||
|
const page2 = await db.search('test item', 3, {
|
||||||
|
service: 'service-a',
|
||||||
|
offset: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check that results are from service-a
|
||||||
|
expect(page1.every(r => r.metadata.createdBy?.augmentation === 'service-a')).toBe(true)
|
||||||
|
expect(page2.every(r => r.metadata.createdBy?.augmentation === 'service-a')).toBe(true)
|
||||||
|
|
||||||
|
// Check no overlap
|
||||||
|
const page1Ids = page1.map(r => r.metadata.id)
|
||||||
|
const page2Ids = page2.map(r => r.metadata.id)
|
||||||
|
expect(page1Ids.filter(id => page2Ids.includes(id)).length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Pagination Consistency', () => {
|
||||||
|
it('should maintain consistent ordering across pages', async () => {
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i.toString().padStart(2, '0')}`,
|
||||||
|
text: `consistent test ${i}`,
|
||||||
|
score: Math.random()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all results in one query
|
||||||
|
const allResults = await db.search('consistent test', 30)
|
||||||
|
const allIds = allResults.map(r => r.metadata.id)
|
||||||
|
|
||||||
|
// Get results in pages
|
||||||
|
const page1 = await db.search('consistent test', 10, { offset: 0 })
|
||||||
|
const page2 = await db.search('consistent test', 10, { offset: 10 })
|
||||||
|
const page3 = await db.search('consistent test', 10, { offset: 20 })
|
||||||
|
|
||||||
|
const pagedIds = [
|
||||||
|
...page1.map(r => r.metadata.id),
|
||||||
|
...page2.map(r => r.metadata.id),
|
||||||
|
...page3.map(r => r.metadata.id)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check that paginated results match the full query
|
||||||
|
expect(pagedIds).toEqual(allIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty results gracefully', async () => {
|
||||||
|
// Search empty database with offset
|
||||||
|
const results = await db.search('nonexistent', 10, { offset: 5 })
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Vector Search with Offset', () => {
|
||||||
|
it('should paginate vector searches', async () => {
|
||||||
|
// Add test vectors
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const vector = new Array(384).fill(0).map(() => Math.random())
|
||||||
|
await db.add({
|
||||||
|
id: `vec-${i}`,
|
||||||
|
vector: vector,
|
||||||
|
index: i
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a query vector
|
||||||
|
const queryVector = new Array(384).fill(0).map(() => Math.random())
|
||||||
|
|
||||||
|
// Get first page
|
||||||
|
const page1 = await db.search(queryVector, 5, { forceEmbed: false })
|
||||||
|
expect(page1.length).toBe(5)
|
||||||
|
|
||||||
|
// Get second page
|
||||||
|
const page2 = await db.search(queryVector, 5, {
|
||||||
|
forceEmbed: false,
|
||||||
|
offset: 5
|
||||||
|
})
|
||||||
|
expect(page2.length).toBe(5)
|
||||||
|
|
||||||
|
// Ensure different results
|
||||||
|
const page1Ids = page1.map(r => r.metadata.id)
|
||||||
|
const page2Ids = page2.map(r => r.metadata.id)
|
||||||
|
expect(page1Ids.filter(id => page2Ids.includes(id)).length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
257
tests/performance-improvements.test.ts
Normal file
257
tests/performance-improvements.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* Tests for performance improvements: caching and cursor-based pagination
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { cleanupWorkerPools } from '../src/utils/index.js'
|
||||||
|
|
||||||
|
describe('Performance Improvements', () => {
|
||||||
|
let db: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Initialize BrainyData with caching enabled
|
||||||
|
db = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
},
|
||||||
|
searchCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxSize: 50,
|
||||||
|
maxAge: 60000 // 1 minute
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
verbose: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await db.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.clear()
|
||||||
|
await cleanupWorkerPools()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search Result Caching', () => {
|
||||||
|
it('should cache search results transparently', async () => {
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`,
|
||||||
|
value: i
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache to start fresh
|
||||||
|
db.clearCache()
|
||||||
|
let stats = db.getCacheStats()
|
||||||
|
expect(stats.search.hits).toBe(0)
|
||||||
|
expect(stats.search.misses).toBe(0)
|
||||||
|
|
||||||
|
// First search - should be cache miss
|
||||||
|
const query = 'test document'
|
||||||
|
const results1 = await db.search(query, 5)
|
||||||
|
expect(results1.length).toBe(5)
|
||||||
|
|
||||||
|
stats = db.getCacheStats()
|
||||||
|
expect(stats.search.misses).toBe(1) // First search is a miss
|
||||||
|
expect(stats.search.hits).toBe(0)
|
||||||
|
|
||||||
|
// Second identical search - should be cache hit
|
||||||
|
const results2 = await db.search(query, 5)
|
||||||
|
expect(results2.length).toBe(5)
|
||||||
|
expect(results2).toEqual(results1) // Same results
|
||||||
|
|
||||||
|
stats = db.getCacheStats()
|
||||||
|
expect(stats.search.misses).toBe(1) // Still only one miss
|
||||||
|
expect(stats.search.hits).toBe(1) // Now we have a hit
|
||||||
|
expect(stats.search.hitRate).toBe(0.5) // 50% hit rate
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should invalidate cache when data changes', async () => {
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search and cache results
|
||||||
|
const results1 = await db.search('test document', 3)
|
||||||
|
expect(results1.length).toBe(3)
|
||||||
|
|
||||||
|
let stats = db.getCacheStats()
|
||||||
|
const initialMisses = stats.search.misses
|
||||||
|
|
||||||
|
// Same search should hit cache
|
||||||
|
await db.search('test document', 3)
|
||||||
|
stats = db.getCacheStats()
|
||||||
|
expect(stats.search.hits).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Add new data - should invalidate cache
|
||||||
|
await db.add({
|
||||||
|
id: 'new-item',
|
||||||
|
text: 'new test document'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Same search should miss cache (due to invalidation)
|
||||||
|
await db.search('test document', 3)
|
||||||
|
stats = db.getCacheStats()
|
||||||
|
// Cache was cleared, so we should have fewer misses recorded than expected
|
||||||
|
// The key point is that cache size should be 0 or low, indicating invalidation worked
|
||||||
|
expect(stats.search.size).toBeLessThanOrEqual(1) // Cache should have been cleared
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle cache with different search parameters', async () => {
|
||||||
|
// Add test data
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `item-${i}`,
|
||||||
|
text: `test document ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db.clearCache()
|
||||||
|
|
||||||
|
// Different k values should create different cache entries
|
||||||
|
await db.search('test document', 3)
|
||||||
|
await db.search('test document', 5) // Different k
|
||||||
|
await db.search('test document', 3) // Should hit cache
|
||||||
|
|
||||||
|
const stats = db.getCacheStats()
|
||||||
|
expect(stats.search.hits).toBe(1)
|
||||||
|
expect(stats.search.misses).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cursor-based Pagination', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add more test data for pagination
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `doc-${i.toString().padStart(2, '0')}`,
|
||||||
|
text: `document content for testing pagination ${i}`,
|
||||||
|
index: i
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return cursor for pagination', async () => {
|
||||||
|
const page1 = await db.searchWithCursor('document content for testing', 10)
|
||||||
|
|
||||||
|
expect(page1.results.length).toBe(10)
|
||||||
|
expect(page1.hasMore).toBe(true)
|
||||||
|
expect(page1.cursor).toBeDefined()
|
||||||
|
expect(page1.cursor!.lastId).toBeDefined()
|
||||||
|
expect(page1.cursor!.lastScore).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should paginate consistently with cursor', async () => {
|
||||||
|
const page1 = await db.searchWithCursor('document content for testing', 5)
|
||||||
|
expect(page1.results.length).toBe(5)
|
||||||
|
expect(page1.hasMore).toBe(true)
|
||||||
|
|
||||||
|
// Get next page using cursor
|
||||||
|
const page2 = await db.searchWithCursor('document content for testing', 5, {
|
||||||
|
cursor: page1.cursor
|
||||||
|
})
|
||||||
|
expect(page2.results.length).toBe(5)
|
||||||
|
|
||||||
|
// Ensure no overlap between pages
|
||||||
|
const page1Ids = page1.results.map(r => r.id)
|
||||||
|
const page2Ids = page2.results.map(r => r.id)
|
||||||
|
const overlap = page1Ids.filter(id => page2Ids.includes(id))
|
||||||
|
expect(overlap.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle last page correctly', async () => {
|
||||||
|
// Get small pages to reach the end
|
||||||
|
let currentCursor: any = undefined
|
||||||
|
let allResults: any[] = []
|
||||||
|
let pageCount = 0
|
||||||
|
const maxPages = 10 // Safety limit
|
||||||
|
|
||||||
|
while (pageCount < maxPages) {
|
||||||
|
const page = await db.searchWithCursor('document content', 5, {
|
||||||
|
cursor: currentCursor
|
||||||
|
})
|
||||||
|
|
||||||
|
allResults.push(...page.results)
|
||||||
|
pageCount++
|
||||||
|
|
||||||
|
if (!page.hasMore) {
|
||||||
|
expect(page.cursor).toBeUndefined()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCursor = page.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(pageCount).toBeLessThan(maxPages) // Should have finished before limit
|
||||||
|
expect(allResults.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide total estimate when possible', async () => {
|
||||||
|
const page = await db.searchWithCursor('document content', 50) // Request more than we have
|
||||||
|
|
||||||
|
// Should get all results in one page
|
||||||
|
expect(page.results.length).toBeGreaterThan(0)
|
||||||
|
expect(page.hasMore).toBe(false)
|
||||||
|
expect(page.totalEstimate).toBeDefined()
|
||||||
|
expect(page.totalEstimate).toBe(page.results.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Characteristics', () => {
|
||||||
|
it('should show performance improvement with caching', async () => {
|
||||||
|
// Add substantial test data
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `perf-test-${i}`,
|
||||||
|
text: `performance test document ${i} with some content`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache and perform first search
|
||||||
|
db.clearCache()
|
||||||
|
const results1 = await db.search('performance test document', 10)
|
||||||
|
|
||||||
|
let stats = db.getCacheStats()
|
||||||
|
expect(stats.search.misses).toBe(1) // First search is a miss
|
||||||
|
expect(stats.search.hits).toBe(0)
|
||||||
|
|
||||||
|
// Perform second identical search (should hit cache)
|
||||||
|
const results2 = await db.search('performance test document', 10)
|
||||||
|
|
||||||
|
expect(results1).toEqual(results2) // Same results
|
||||||
|
|
||||||
|
stats = db.getCacheStats()
|
||||||
|
expect(stats.search.hits).toBe(1) // Second search is a hit
|
||||||
|
expect(stats.search.hitRate).toBe(0.5) // 50% hit rate (1 hit, 1 miss)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide cache memory usage information', async () => {
|
||||||
|
// Add some data and search to populate cache
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await db.add({
|
||||||
|
id: `mem-test-${i}`,
|
||||||
|
text: `memory test ${i}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
db.clearCache()
|
||||||
|
|
||||||
|
// Perform several searches to populate cache
|
||||||
|
await db.search('memory test', 5)
|
||||||
|
await db.search('memory test', 3)
|
||||||
|
await db.search('test', 5)
|
||||||
|
|
||||||
|
const stats = db.getCacheStats()
|
||||||
|
expect(stats.searchMemoryUsage).toBeGreaterThan(0)
|
||||||
|
expect(stats.search.size).toBeGreaterThan(0)
|
||||||
|
expect(stats.search.size).toBeLessThanOrEqual(stats.search.maxSize)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
234
tests/performance.test.ts
Normal file
234
tests/performance.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/**
|
||||||
|
* Performance Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite measures the performance of Brainy operations with different dataset sizes:
|
||||||
|
* 1. Small datasets (10-100 items)
|
||||||
|
* 2. Medium datasets (100-1000 items)
|
||||||
|
* 3. Large datasets (1000+ items)
|
||||||
|
*
|
||||||
|
* These tests help identify performance bottlenecks and ensure the library
|
||||||
|
* remains efficient as the dataset grows.
|
||||||
|
*
|
||||||
|
* Note: These tests are marked as "slow" and may take longer to run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData, createStorage } from '../dist/unified.js'
|
||||||
|
|
||||||
|
// Helper function to measure execution time
|
||||||
|
const measureExecutionTime = async (fn: () => Promise<any>): Promise<number> => {
|
||||||
|
const start = performance.now()
|
||||||
|
await fn()
|
||||||
|
const end = performance.now()
|
||||||
|
return end - start
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate test data
|
||||||
|
const generateTestData = (count: number): string[] => {
|
||||||
|
return Array.from({ length: count }, (_, i) => `Test item ${i} with some additional text for embedding`)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Performance Tests', () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test BrainyData instance with memory storage for faster tests
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure a clean test environment
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up after each test
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Small Dataset (10-100 items)', () => {
|
||||||
|
it('should add items efficiently', async () => {
|
||||||
|
const items = generateTestData(50)
|
||||||
|
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Adding 50 items took ${executionTime.toFixed(2)}ms (${(executionTime / 50).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Verify all items were added
|
||||||
|
const size = await brainyInstance.size()
|
||||||
|
expect(size).toBe(50)
|
||||||
|
|
||||||
|
// No specific performance assertion, just logging for analysis
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(50)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Measure search performance
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.search('Test item', 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Searching in 50 items took ${executionTime.toFixed(2)}ms`)
|
||||||
|
|
||||||
|
// No specific performance assertion, just logging for analysis
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Medium Dataset (100-1000 items)', () => {
|
||||||
|
it('should add items efficiently', async () => {
|
||||||
|
const items = generateTestData(200)
|
||||||
|
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Adding 200 items took ${executionTime.toFixed(2)}ms (${(executionTime / 200).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Verify all items were added
|
||||||
|
const size = await brainyInstance.size()
|
||||||
|
expect(size).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(200)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Measure search performance
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.search('Test item', 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Searching in 200 items took ${executionTime.toFixed(2)}ms`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple concurrent searches efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(200)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Perform multiple concurrent searches
|
||||||
|
const searchQueries = [
|
||||||
|
'Test item 10',
|
||||||
|
'Test item 50',
|
||||||
|
'Test item 100',
|
||||||
|
'Test item 150',
|
||||||
|
'Test item 190'
|
||||||
|
]
|
||||||
|
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await Promise.all(searchQueries.map(query => brainyInstance.search(query, 10)))
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`5 concurrent searches in 200 items took ${executionTime.toFixed(2)}ms (${(executionTime / 5).toFixed(2)}ms per search)`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Large dataset tests are skipped by default as they can be slow
|
||||||
|
// Use .only instead of .skip to run these tests specifically
|
||||||
|
describe.skip('Large Dataset (1000+ items)', () => {
|
||||||
|
it('should add items efficiently', async () => {
|
||||||
|
const items = generateTestData(1000)
|
||||||
|
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Adding 1000 items took ${executionTime.toFixed(2)}ms (${(executionTime / 1000).toFixed(2)}ms per item)`)
|
||||||
|
|
||||||
|
// Verify all items were added
|
||||||
|
const size = await brainyInstance.size()
|
||||||
|
expect(size).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(1000)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Measure search performance
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.search('Test item', 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Searching in 1000 items took ${executionTime.toFixed(2)}ms`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple concurrent searches efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(1000)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Perform multiple concurrent searches
|
||||||
|
const searchQueries = [
|
||||||
|
'Test item 100',
|
||||||
|
'Test item 300',
|
||||||
|
'Test item 500',
|
||||||
|
'Test item 700',
|
||||||
|
'Test item 900'
|
||||||
|
]
|
||||||
|
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await Promise.all(searchQueries.map(query => brainyInstance.search(query, 10)))
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`5 concurrent searches in 1000 items took ${executionTime.toFixed(2)}ms (${(executionTime / 5).toFixed(2)}ms per search)`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Scaling', () => {
|
||||||
|
it('should demonstrate search performance scaling with dataset size', async () => {
|
||||||
|
// Test with different dataset sizes
|
||||||
|
const datasetSizes = [10, 50, 100]
|
||||||
|
const results: { size: number; time: number }[] = []
|
||||||
|
|
||||||
|
for (const size of datasetSizes) {
|
||||||
|
// Add test data
|
||||||
|
const items = generateTestData(size)
|
||||||
|
await brainyInstance.addBatch(items)
|
||||||
|
|
||||||
|
// Measure search performance
|
||||||
|
const executionTime = await measureExecutionTime(async () => {
|
||||||
|
await brainyInstance.search('Test item', 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({ size, time: executionTime })
|
||||||
|
|
||||||
|
// Clear for next iteration
|
||||||
|
await brainyInstance.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
console.log('Search Performance Scaling:')
|
||||||
|
results.forEach(result => {
|
||||||
|
console.log(`Dataset size: ${result.size}, Search time: ${result.time.toFixed(2)}ms`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate scaling factor (how much slower per item)
|
||||||
|
if (results.length >= 2) {
|
||||||
|
const smallestDataset = results[0]
|
||||||
|
const largestDataset = results[results.length - 1]
|
||||||
|
|
||||||
|
const scalingFactor = (largestDataset.time / smallestDataset.time) /
|
||||||
|
(largestDataset.size / smallestDataset.size)
|
||||||
|
|
||||||
|
console.log(`Scaling factor: ${scalingFactor.toFixed(2)}x`)
|
||||||
|
|
||||||
|
// Ideally, the scaling factor should be close to 1 (linear scaling)
|
||||||
|
// or less than 1 (sub-linear scaling)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
346
tests/regression.test.ts
Normal file
346
tests/regression.test.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
/**
|
||||||
|
* Regression Test Suite for Brainy
|
||||||
|
*
|
||||||
|
* This test suite verifies that core functionality works across:
|
||||||
|
* - All storage adapters
|
||||||
|
* - All environments (Node.js, Browser simulation)
|
||||||
|
* - Performance benchmarks
|
||||||
|
* - Package size limits
|
||||||
|
* - CLI and API consistency
|
||||||
|
*
|
||||||
|
* These tests should ALWAYS pass before any release.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData, NounType, VerbType, MemoryStorage, OPFSStorage, createEmbeddingFunction } from '../src/index.js'
|
||||||
|
import { performance } from 'perf_hooks'
|
||||||
|
|
||||||
|
describe('Brainy Regression Tests', () => {
|
||||||
|
|
||||||
|
describe('Core Functionality Across Storage Adapters', () => {
|
||||||
|
const storageAdapters = [
|
||||||
|
{ name: 'Memory', create: () => new MemoryStorage() },
|
||||||
|
// Note: FileSystem and OPFS require different test environments
|
||||||
|
// { name: 'OPFS', create: () => new OPFSStorage() }, // Browser only
|
||||||
|
// { name: 'FileSystem', create: () => new FileSystemStorage('./test-fs') }, // Node only
|
||||||
|
]
|
||||||
|
|
||||||
|
storageAdapters.forEach(({ name, create }) => {
|
||||||
|
describe(`${name} Storage`, () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: create()
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle basic add and search operations', async () => {
|
||||||
|
const id = await brainy.add("Test data for regression testing")
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const results = await brainy.search("regression testing", 5)
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].id).toBe(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle typed noun and verb operations', async () => {
|
||||||
|
const personId = await brainy.addNoun("John Doe", NounType.Person)
|
||||||
|
const companyId = await brainy.addNoun("Tech Corp", NounType.Organization)
|
||||||
|
const verbId = await brainy.addVerb(personId, companyId, VerbType.WorksWith)
|
||||||
|
|
||||||
|
expect(personId).toBeDefined()
|
||||||
|
expect(companyId).toBeDefined()
|
||||||
|
expect(verbId).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle metadata filtering', async () => {
|
||||||
|
await brainy.add("Item 1", { category: "A", priority: 1 })
|
||||||
|
await brainy.add("Item 2", { category: "B", priority: 2 })
|
||||||
|
|
||||||
|
const results = await brainy.search("", 10, {
|
||||||
|
metadata: { category: "A" }
|
||||||
|
})
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0].metadata.category).toBe("A")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle update operations', async () => {
|
||||||
|
const id = await brainy.add("Original content", { version: 1 })
|
||||||
|
const success = await brainy.update(id, "Updated content", { version: 2 })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
const results = await brainy.search("Updated content", 5)
|
||||||
|
expect(results[0].metadata.version).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle soft delete (default behavior)', async () => {
|
||||||
|
const id = await brainy.add("Content to delete")
|
||||||
|
const success = await brainy.delete(id) // Soft delete by default
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
// Should not appear in search results
|
||||||
|
const results = await brainy.search("Content to delete", 10)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Benchmarks', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add 100 items within performance threshold', async () => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
promises.push(brainy.add(`Test item ${i}`))
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
const duration = endTime - startTime
|
||||||
|
|
||||||
|
// Should complete within 10 seconds (generous threshold for CI)
|
||||||
|
expect(duration).toBeLessThan(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search through 1000+ items efficiently', async () => {
|
||||||
|
// Add test data
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
promises.push(brainy.add(`Document ${i} about various topics and information`))
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
// Benchmark search
|
||||||
|
const startTime = performance.now()
|
||||||
|
const results = await brainy.search("document topics", 10)
|
||||||
|
const endTime = performance.now()
|
||||||
|
const duration = endTime - startTime
|
||||||
|
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
// Search should complete within 1 second
|
||||||
|
expect(duration).toBeLessThan(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle batch import efficiently', async () => {
|
||||||
|
const data = Array.from({ length: 50 }, (_, i) => `Batch item ${i}`)
|
||||||
|
|
||||||
|
const startTime = performance.now()
|
||||||
|
const ids = await brainy.import(data)
|
||||||
|
const endTime = performance.now()
|
||||||
|
const duration = endTime - startTime
|
||||||
|
|
||||||
|
expect(ids.length).toBe(50)
|
||||||
|
// Batch import should be faster than individual adds
|
||||||
|
expect(duration).toBeLessThan(5000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Environment Compatibility', () => {
|
||||||
|
it('should detect Node.js environment correctly', async () => {
|
||||||
|
const { isNode, isBrowser, isWebWorker } = await import('../src/index.js')
|
||||||
|
|
||||||
|
expect(isNode()).toBe(true)
|
||||||
|
expect(isBrowser()).toBe(false)
|
||||||
|
expect(isWebWorker()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create embedding functions in Node.js', async () => {
|
||||||
|
const embeddingFn = await createEmbeddingFunction()
|
||||||
|
expect(typeof embeddingFn).toBe('function')
|
||||||
|
|
||||||
|
const embedding = await embeddingFn("test text")
|
||||||
|
expect(Array.isArray(embedding)).toBe(true)
|
||||||
|
expect(embedding.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Data Integrity', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain data consistency across operations', async () => {
|
||||||
|
// Add initial data
|
||||||
|
const id1 = await brainy.add("Document about machine learning")
|
||||||
|
const id2 = await brainy.add("Article about artificial intelligence")
|
||||||
|
|
||||||
|
// Verify both exist
|
||||||
|
let results = await brainy.search("machine learning", 10)
|
||||||
|
expect(results.some(r => r.id === id1)).toBe(true)
|
||||||
|
|
||||||
|
results = await brainy.search("artificial intelligence", 10)
|
||||||
|
expect(results.some(r => r.id === id2)).toBe(true)
|
||||||
|
|
||||||
|
// Update one item
|
||||||
|
await brainy.update(id1, "Updated document about deep learning")
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
results = await brainy.search("deep learning", 10)
|
||||||
|
expect(results.some(r => r.id === id1)).toBe(true)
|
||||||
|
|
||||||
|
// Original content should not be found
|
||||||
|
results = await brainy.search("machine learning", 10)
|
||||||
|
expect(results.some(r => r.id === id1)).toBe(false)
|
||||||
|
|
||||||
|
// Other document should remain unchanged
|
||||||
|
results = await brainy.search("artificial intelligence", 10)
|
||||||
|
expect(results.some(r => r.id === id2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle concurrent operations without corruption', async () => {
|
||||||
|
const concurrentOperations = []
|
||||||
|
|
||||||
|
// Start multiple operations concurrently
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
concurrentOperations.push(brainy.add(`Concurrent item ${i}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = await Promise.all(concurrentOperations)
|
||||||
|
expect(ids.length).toBe(20)
|
||||||
|
|
||||||
|
// Verify all items can be found
|
||||||
|
const results = await brainy.search("Concurrent item", 25)
|
||||||
|
expect(results.length).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling & Edge Cases', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty search queries gracefully', async () => {
|
||||||
|
await brainy.add("Some content")
|
||||||
|
|
||||||
|
const results = await brainy.search("", 10)
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
// Empty query might return all results or none, but should not throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle invalid IDs gracefully', async () => {
|
||||||
|
const result = await brainy.update("invalid-id", "new data")
|
||||||
|
expect(result).toBe(false)
|
||||||
|
|
||||||
|
const deleteResult = await brainy.delete("invalid-id")
|
||||||
|
expect(deleteResult).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle very large text content', async () => {
|
||||||
|
const largeText = "Lorem ipsum ".repeat(1000) // ~11KB text
|
||||||
|
const id = await brainy.add(largeText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const results = await brainy.search("Lorem ipsum", 5)
|
||||||
|
expect(results.some(r => r.id === id)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle special characters and unicode', async () => {
|
||||||
|
const specialText = "Hello 世界! 🌍 Special chars: @#$%^&*()_+-=[]{}|;':\",./<>?"
|
||||||
|
const id = await brainy.add(specialText)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const results = await brainy.search("世界", 5)
|
||||||
|
expect(results.some(r => r.id === id)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Package Size Regression', () => {
|
||||||
|
it('should not exceed package size threshold', async () => {
|
||||||
|
// This is a placeholder test - actual implementation would check built package size
|
||||||
|
// For now, we'll just verify core imports don't significantly bloat
|
||||||
|
const coreModules = await import('../src/index.js')
|
||||||
|
|
||||||
|
// Verify main exports exist (prevents tree-shaking regression)
|
||||||
|
expect(coreModules.BrainyData).toBeDefined()
|
||||||
|
expect(coreModules.NounType).toBeDefined()
|
||||||
|
expect(coreModules.VerbType).toBeDefined()
|
||||||
|
expect(coreModules.createEmbeddingFunction).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Configuration & Initialization', () => {
|
||||||
|
it('should initialize with default configuration', async () => {
|
||||||
|
const brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should not throw and should be usable
|
||||||
|
const id = await brainy.add("Test initialization")
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with custom configuration', async () => {
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
maxNeighbors: 32,
|
||||||
|
efConstruction: 400,
|
||||||
|
storage: new MemoryStorage()
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const id = await brainy.add("Test custom config")
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple instances', async () => {
|
||||||
|
const brainy1 = new BrainyData()
|
||||||
|
const brainy2 = new BrainyData()
|
||||||
|
|
||||||
|
await brainy1.init()
|
||||||
|
await brainy2.init()
|
||||||
|
|
||||||
|
// Both should work independently
|
||||||
|
const id1 = await brainy1.add("Instance 1 data")
|
||||||
|
const id2 = await brainy2.add("Instance 2 data")
|
||||||
|
|
||||||
|
expect(id1).toBeDefined()
|
||||||
|
expect(id2).toBeDefined()
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
|
||||||
|
await brainy1.destroy()
|
||||||
|
await brainy2.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
258
tests/release-validation.test.ts
Normal file
258
tests/release-validation.test.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Release Validation Test Suite
|
||||||
|
*
|
||||||
|
* This comprehensive suite must PASS before any release.
|
||||||
|
* It validates all critical functionality and prevents regressions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
describe('Release Validation', () => {
|
||||||
|
|
||||||
|
describe('Package Integrity', () => {
|
||||||
|
it('should have valid package.json', () => {
|
||||||
|
const packagePath = path.join(process.cwd(), 'package.json')
|
||||||
|
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
|
||||||
|
expect(packageJson.name).toBe('@soulcraft/brainy')
|
||||||
|
expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+/)
|
||||||
|
expect(packageJson.main).toBe('dist/index.js')
|
||||||
|
expect(packageJson.types).toBe('dist/index.d.ts')
|
||||||
|
expect(packageJson.bin.brainy).toBe('./bin/brainy.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build without errors', () => {
|
||||||
|
expect(() => {
|
||||||
|
execSync('npm run build', {
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have reasonable package size', () => {
|
||||||
|
try {
|
||||||
|
// Check if dist directory exists and has content
|
||||||
|
const output = execSync('du -sh dist/', { encoding: 'utf-8' })
|
||||||
|
const sizeMatch = output.match(/^([\d.]+)([KMGT]?)/)
|
||||||
|
|
||||||
|
if (sizeMatch) {
|
||||||
|
const [, size, unit] = sizeMatch
|
||||||
|
const sizeNum = parseFloat(size)
|
||||||
|
|
||||||
|
// Package should be under 50MB total
|
||||||
|
if (unit === 'M') {
|
||||||
|
expect(sizeNum).toBeLessThan(50)
|
||||||
|
} else if (unit === 'K') {
|
||||||
|
// KB is fine
|
||||||
|
expect(sizeNum).toBeLessThan(50000) // 50MB in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If du command fails, just check that dist exists
|
||||||
|
expect(true).toBe(true) // Always pass if we can't check size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core API Validation', () => {
|
||||||
|
it('should export all required 1.0 API methods', async () => {
|
||||||
|
const brainyModule = await import('../src/index.js')
|
||||||
|
const { BrainyData } = brainyModule
|
||||||
|
|
||||||
|
const instance = new BrainyData()
|
||||||
|
|
||||||
|
// Validate 7 core methods exist
|
||||||
|
expect(typeof instance.add).toBe('function')
|
||||||
|
expect(typeof instance.search).toBe('function')
|
||||||
|
expect(typeof instance.import).toBe('function')
|
||||||
|
expect(typeof instance.addNoun).toBe('function')
|
||||||
|
expect(typeof instance.addVerb).toBe('function')
|
||||||
|
expect(typeof instance.update).toBe('function')
|
||||||
|
expect(typeof instance.delete).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export encryption methods', async () => {
|
||||||
|
const brainyModule = await import('../src/index.js')
|
||||||
|
const { BrainyData } = brainyModule
|
||||||
|
|
||||||
|
const instance = new BrainyData()
|
||||||
|
|
||||||
|
expect(typeof instance.encryptData).toBe('function')
|
||||||
|
expect(typeof instance.decryptData).toBe('function')
|
||||||
|
expect(typeof instance.setConfig).toBe('function')
|
||||||
|
expect(typeof instance.getConfig).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export graph types', async () => {
|
||||||
|
const { NounType, VerbType } = await import('../src/index.js')
|
||||||
|
|
||||||
|
expect(NounType).toBeDefined()
|
||||||
|
expect(VerbType).toBeDefined()
|
||||||
|
|
||||||
|
// Check some key types exist
|
||||||
|
expect(NounType.Person).toBe('person')
|
||||||
|
expect(NounType.Organization).toBe('organization')
|
||||||
|
expect(VerbType.WorksWith).toBe('worksWith')
|
||||||
|
expect(VerbType.RelatedTo).toBe('relatedTo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should export container preloading methods', async () => {
|
||||||
|
const { BrainyData } = await import('../src/index.js')
|
||||||
|
|
||||||
|
expect(typeof BrainyData.preloadModel).toBe('function')
|
||||||
|
expect(typeof BrainyData.warmup).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CLI Validation', () => {
|
||||||
|
const CLI_PATH = path.resolve('./bin/brainy.js')
|
||||||
|
|
||||||
|
it('should have executable CLI', () => {
|
||||||
|
expect(() => {
|
||||||
|
execSync(`node ${CLI_PATH} --help`, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show correct version', () => {
|
||||||
|
const output = execSync(`node ${CLI_PATH} --version`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output).toMatch(/\d+\.\d+\.\d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should list all 9 core commands in help', () => {
|
||||||
|
const output = execSync(`node ${CLI_PATH} --help`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should contain all 9 unified commands
|
||||||
|
expect(output).toContain('init')
|
||||||
|
expect(output).toContain('add')
|
||||||
|
expect(output).toContain('search')
|
||||||
|
expect(output).toContain('update')
|
||||||
|
expect(output).toContain('delete')
|
||||||
|
expect(output).toContain('import')
|
||||||
|
expect(output).toContain('status')
|
||||||
|
expect(output).toContain('config')
|
||||||
|
expect(output).toContain('chat')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Documentation Validation', () => {
|
||||||
|
it('should have comprehensive CHANGELOG.md', () => {
|
||||||
|
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md')
|
||||||
|
const changelog = readFileSync(changelogPath, 'utf-8')
|
||||||
|
|
||||||
|
expect(changelog).toContain('1.0.0-rc.1')
|
||||||
|
expect(changelog).toContain('BREAKING CHANGES')
|
||||||
|
expect(changelog).toContain('Unified API')
|
||||||
|
expect(changelog).toContain('CLI TRANSFORMATION')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have migration guide', () => {
|
||||||
|
const migrationPath = path.join(process.cwd(), 'MIGRATION.md')
|
||||||
|
const migration = readFileSync(migrationPath, 'utf-8')
|
||||||
|
|
||||||
|
expect(migration).toContain('Migration Guide: Brainy 0.x → 1.0')
|
||||||
|
expect(migration).toContain('addSmart()')
|
||||||
|
expect(migration).toContain('add()')
|
||||||
|
expect(migration).toContain('CLI Command Changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have README.md', () => {
|
||||||
|
const readmePath = path.join(process.cwd(), 'README.md')
|
||||||
|
const readme = readFileSync(readmePath, 'utf-8')
|
||||||
|
|
||||||
|
expect(readme.length).toBeGreaterThan(1000) // Should have substantial content
|
||||||
|
expect(readme).toContain('Brainy')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Environment Compatibility', () => {
|
||||||
|
it('should work in Node.js environment', async () => {
|
||||||
|
const { BrainyData, isNode } = await import('../src/index.js')
|
||||||
|
|
||||||
|
expect(isNode()).toBe(true)
|
||||||
|
|
||||||
|
// Should be able to create and init instance
|
||||||
|
const brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Basic functionality should work
|
||||||
|
const id = await brainy.add("Release validation test")
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have proper TypeScript definitions', () => {
|
||||||
|
const packagePath = path.join(process.cwd(), 'package.json')
|
||||||
|
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
|
||||||
|
expect(packageJson.types).toBe('dist/index.d.ts')
|
||||||
|
|
||||||
|
// Check that dist directory exists (should be built)
|
||||||
|
expect(() => {
|
||||||
|
execSync('ls dist/index.d.ts', { stdio: 'pipe' })
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dependency Security', () => {
|
||||||
|
it('should have reasonable dependency count', () => {
|
||||||
|
const packagePath = path.join(process.cwd(), 'package.json')
|
||||||
|
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
||||||
|
|
||||||
|
const depCount = Object.keys(packageJson.dependencies || {}).length
|
||||||
|
const devDepCount = Object.keys(packageJson.devDependencies || {}).length
|
||||||
|
|
||||||
|
// Should not have excessive dependencies
|
||||||
|
expect(depCount).toBeLessThan(20) // Production dependencies
|
||||||
|
expect(devDepCount).toBeLessThan(30) // Development dependencies
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not have high-severity vulnerabilities', () => {
|
||||||
|
try {
|
||||||
|
// Run npm audit to check for vulnerabilities
|
||||||
|
execSync('npm audit --audit-level=high', {
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
// If it doesn't throw, we're good
|
||||||
|
expect(true).toBe(true)
|
||||||
|
} catch (error: any) {
|
||||||
|
// npm audit returns non-zero exit code for vulnerabilities
|
||||||
|
// We'll be lenient for now but should investigate if this fails
|
||||||
|
console.warn('npm audit found potential security issues:', error.message)
|
||||||
|
expect(true).toBe(true) // Don't fail the test, just warn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Performance Baseline', () => {
|
||||||
|
it('should maintain acceptable initialization time', async () => {
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
const { BrainyData } = await import('../src/index.js')
|
||||||
|
const brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
const initTime = Date.now() - start
|
||||||
|
|
||||||
|
// Should initialize within 5 seconds
|
||||||
|
expect(initTime).toBeLessThan(5000)
|
||||||
|
|
||||||
|
await brainy.cleanup()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
1
tests/results/test-results.json
Normal file
1
tests/results/test-results.json
Normal file
File diff suppressed because one or more lines are too long
654
tests/s3-comprehensive.test.ts
Normal file
654
tests/s3-comprehensive.test.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
/**
|
||||||
|
* COMPREHENSIVE S3 Storage Tests
|
||||||
|
*
|
||||||
|
* This test suite covers ALL S3-based features to ensure production reliability at scale.
|
||||||
|
* Tests include: statistics, nouns, verbs, metadata, HNSW index, caching, and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { mockClient } from 'aws-sdk-client-mock'
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
DeleteObjectsCommand
|
||||||
|
} from '@aws-sdk/client-s3'
|
||||||
|
import { BrainyData } from '../src/index.js'
|
||||||
|
import { createMockEmbeddingFunction, createMockS3Body } from './test-utils.js'
|
||||||
|
|
||||||
|
// Create S3 mock
|
||||||
|
const s3Mock = mockClient(S3Client)
|
||||||
|
|
||||||
|
// Use the shared mock body helper
|
||||||
|
const createMockBody = createMockS3Body
|
||||||
|
|
||||||
|
describe('COMPREHENSIVE S3 Storage Tests', () => {
|
||||||
|
let brainy: BrainyData<any>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
s3Mock.reset()
|
||||||
|
vi.clearAllTimers()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.clear()
|
||||||
|
}
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core S3 Operations', () => {
|
||||||
|
describe('Nouns (Vector Data)', () => {
|
||||||
|
it('should save and retrieve nouns from S3', async () => {
|
||||||
|
const nounData = {
|
||||||
|
id: 'test-noun-1',
|
||||||
|
vector: [0.1, 0.2, 0.3],
|
||||||
|
connections: {},
|
||||||
|
level: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock S3 responses
|
||||||
|
s3Mock.on(GetObjectCommand, {
|
||||||
|
Key: 'nouns/test-noun-1.json'
|
||||||
|
}).resolves({
|
||||||
|
Body: createMockBody(nounData)
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add a noun (use string so it gets embedded)
|
||||||
|
const id = await brainy.add('Test noun content', { name: 'Test noun' })
|
||||||
|
|
||||||
|
// Verify noun was saved to S3
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
const nounSave = putCalls.find(call =>
|
||||||
|
call.args[0].input.Key?.includes('nouns/')
|
||||||
|
)
|
||||||
|
expect(nounSave).toBeDefined()
|
||||||
|
|
||||||
|
// Retrieve the noun
|
||||||
|
const retrieved = await brainy.get(id)
|
||||||
|
expect(retrieved).toBeDefined()
|
||||||
|
expect(retrieved?.metadata?.name).toBe('Test noun')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle batch noun operations efficiently', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add batch of nouns
|
||||||
|
const items = Array(100).fill(0).map((_, i) => ({
|
||||||
|
data: `Item ${i}`,
|
||||||
|
metadata: { index: i }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ids = await brainy.addBatch(
|
||||||
|
items.map(item => item.data),
|
||||||
|
items.map(item => item.metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ids.length).toBe(100)
|
||||||
|
|
||||||
|
// Verify batch operations are optimized
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
// Should batch operations, not 100 individual calls
|
||||||
|
expect(putCalls.length).toBeLessThan(200) // Some batching should occur
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Verbs (Relationships)', () => {
|
||||||
|
it('should save and retrieve verbs from S3', async () => {
|
||||||
|
const verbData = {
|
||||||
|
id: 'verb-1',
|
||||||
|
source: 'noun-1',
|
||||||
|
target: 'noun-2',
|
||||||
|
type: 'relates_to',
|
||||||
|
vector: [0.4, 0.5, 0.6],
|
||||||
|
weight: 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({
|
||||||
|
Contents: [{
|
||||||
|
Key: 'verbs/verb-1.json'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock verb retrieval
|
||||||
|
s3Mock.on(GetObjectCommand, {
|
||||||
|
Key: 'verbs/verb-1.json'
|
||||||
|
}).resolves({
|
||||||
|
Body: createMockBody(verbData)
|
||||||
|
})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Create nouns first
|
||||||
|
const id1 = await brainy.add('Noun 1', { type: 'entity' })
|
||||||
|
const id2 = await brainy.add('Noun 2', { type: 'entity' })
|
||||||
|
|
||||||
|
// Create relationship
|
||||||
|
await brainy.relate(id1, id2, 'relates_to')
|
||||||
|
|
||||||
|
// Verify verb was saved
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
const verbSave = putCalls.find(call =>
|
||||||
|
call.args[0].input.Key?.includes('verbs/')
|
||||||
|
)
|
||||||
|
expect(verbSave).toBeDefined()
|
||||||
|
|
||||||
|
// Get verbs by source
|
||||||
|
const verbs = await brainy.getVerbsBySource(id1)
|
||||||
|
expect(verbs.length).toBeGreaterThanOrEqual(0) // May be 0 if not mocked fully
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Metadata', () => {
|
||||||
|
it('should handle metadata storage and retrieval', async () => {
|
||||||
|
const metadata = {
|
||||||
|
name: 'Test Item',
|
||||||
|
category: 'test',
|
||||||
|
tags: ['tag1', 'tag2'],
|
||||||
|
nested: {
|
||||||
|
property: 'value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add item with complex metadata
|
||||||
|
const id = await brainy.add('Test data', metadata)
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await brainy.updateMetadata(id, {
|
||||||
|
...metadata,
|
||||||
|
updated: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify metadata was saved
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
const metadataSave = putCalls.find(call =>
|
||||||
|
call.args[0].input.Key?.includes('metadata/')
|
||||||
|
)
|
||||||
|
expect(metadataSave).toBeDefined()
|
||||||
|
|
||||||
|
// Retrieve metadata
|
||||||
|
const retrieved = await brainy.getMetadata(id)
|
||||||
|
expect(retrieved).toBeDefined()
|
||||||
|
expect(retrieved?.name).toBe('Test Item')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle batch metadata operations', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add multiple items
|
||||||
|
const ids = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const id = await brainy.add(`Item ${i}`, { index: i })
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch get metadata
|
||||||
|
const metadataList = await brainy.getBatch(ids)
|
||||||
|
expect(metadataList.length).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HNSW Index', () => {
|
||||||
|
it('should save and load HNSW index from S3', async () => {
|
||||||
|
const indexData = {
|
||||||
|
nodes: {
|
||||||
|
'node-1': {
|
||||||
|
id: 'node-1',
|
||||||
|
vector: [0.1, 0.2, 0.3],
|
||||||
|
connections: {
|
||||||
|
0: ['node-2', 'node-3']
|
||||||
|
},
|
||||||
|
level: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
entryPoint: 'node-1',
|
||||||
|
dimensions: 3,
|
||||||
|
efConstruction: 200,
|
||||||
|
m: 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock index retrieval
|
||||||
|
s3Mock.on(GetObjectCommand, {
|
||||||
|
Key: 'index/hnsw-index.json'
|
||||||
|
}).resolves({
|
||||||
|
Body: createMockBody(indexData)
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add items to build index
|
||||||
|
await brainy.add('Vector 1 content', { name: 'Vector 1' })
|
||||||
|
await brainy.add('Vector 2 content', { name: 'Vector 2' })
|
||||||
|
|
||||||
|
// Search to verify index works
|
||||||
|
const results = await brainy.search('search query', 5)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3 Error Handling', () => {
|
||||||
|
it('should handle S3 connection failures gracefully', async () => {
|
||||||
|
// Simulate S3 connection failure
|
||||||
|
s3Mock.on(GetObjectCommand).rejects(new Error('Connection timeout'))
|
||||||
|
s3Mock.on(PutObjectCommand).rejects(new Error('Connection timeout'))
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should handle initialization failure gracefully
|
||||||
|
await expect(brainy.init()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retry on transient S3 errors', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
// Fail first 2 attempts, succeed on third
|
||||||
|
s3Mock.on(PutObjectCommand).callsFake(() => {
|
||||||
|
attempts++
|
||||||
|
if (attempts < 3) {
|
||||||
|
const error: any = new Error('Service Unavailable')
|
||||||
|
error.$metadata = { httpStatusCode: 503 }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should retry and eventually succeed
|
||||||
|
await brainy.add('Test data', { metadata: 'test' })
|
||||||
|
|
||||||
|
// Verify retries occurred
|
||||||
|
expect(attempts).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle S3 permission errors', async () => {
|
||||||
|
// Simulate permission denied
|
||||||
|
const permissionError: any = new Error('Access Denied')
|
||||||
|
permissionError.name = 'AccessDenied'
|
||||||
|
permissionError.$metadata = { httpStatusCode: 403 }
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).rejects(permissionError)
|
||||||
|
s3Mock.on(PutObjectCommand).rejects(permissionError)
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'invalid-key',
|
||||||
|
secretAccessKey: 'invalid-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should handle permission errors without crashing
|
||||||
|
await expect(brainy.init()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3 Performance Optimizations', () => {
|
||||||
|
it('should use multipart upload for large objects', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Create large dataset
|
||||||
|
const largeData = Array(1000).fill(0).map((_, i) => ({
|
||||||
|
data: `Large item ${i}`,
|
||||||
|
metadata: {
|
||||||
|
index: i,
|
||||||
|
largeField: 'x'.repeat(1000) // Make metadata large
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Add large batch
|
||||||
|
await brainy.addBatch(
|
||||||
|
largeData.map(d => d.data),
|
||||||
|
largeData.map(d => d.metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify data was uploaded
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
expect(putCalls.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should implement caching to reduce S3 calls', async () => {
|
||||||
|
const testData = {
|
||||||
|
id: 'cached-item',
|
||||||
|
vector: [0.1, 0.2, 0.3],
|
||||||
|
metadata: { cached: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
let getCalls = 0
|
||||||
|
s3Mock.on(GetObjectCommand).callsFake((input) => {
|
||||||
|
getCalls++
|
||||||
|
if (input.Key?.includes('nouns/cached-item')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
Body: createMockBody(testData)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.reject({ name: 'NoSuchKey' })
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
},
|
||||||
|
cacheConfig: {
|
||||||
|
hotCacheMaxSize: 100,
|
||||||
|
warmCacheTTL: 60000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
const id = await brainy.add('Cached content', { cached: true }, { id: 'cached-item' })
|
||||||
|
|
||||||
|
// First get - should hit S3
|
||||||
|
await brainy.get(id)
|
||||||
|
const firstGetCalls = getCalls
|
||||||
|
|
||||||
|
// Second get - should hit cache
|
||||||
|
await brainy.get(id)
|
||||||
|
const secondGetCalls = getCalls
|
||||||
|
|
||||||
|
// Cache should prevent additional S3 call
|
||||||
|
expect(secondGetCalls).toBe(firstGetCalls)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parallelize S3 operations for better throughput', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add multiple items concurrently
|
||||||
|
const startTime = Date.now()
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
promises.push(
|
||||||
|
brainy.add(`Parallel item ${i}`, { index: i })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = await Promise.all(promises)
|
||||||
|
const endTime = Date.now()
|
||||||
|
|
||||||
|
expect(ids.length).toBe(20)
|
||||||
|
|
||||||
|
// Operations should be parallelized (fast)
|
||||||
|
// In real scenario, this would be much faster than sequential
|
||||||
|
expect(endTime - startTime).toBeLessThan(5000) // Should be fast due to mocking
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3 Data Integrity', () => {
|
||||||
|
it('should verify data integrity with checksums', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
|
||||||
|
// Track checksums in PUT operations
|
||||||
|
const putChecksums: string[] = []
|
||||||
|
s3Mock.on(PutObjectCommand).callsFake((input) => {
|
||||||
|
if (input.ChecksumAlgorithm || input.ChecksumCRC32) {
|
||||||
|
putChecksums.push(input.Key || '')
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data
|
||||||
|
await brainy.add('Important data', { critical: true })
|
||||||
|
|
||||||
|
// Verify checksum was used for critical data
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
expect(putCalls.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle corrupted data gracefully', async () => {
|
||||||
|
// Return corrupted JSON
|
||||||
|
s3Mock.on(GetObjectCommand).resolves({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => '{ corrupt json ][',
|
||||||
|
transformToByteArray: async () => new TextEncoder().encode('{ corrupt json ][')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should handle corrupted data without crashing
|
||||||
|
await expect(brainy.init()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3 Cleanup Operations', () => {
|
||||||
|
it('should properly clean up S3 objects on delete', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(DeleteObjectCommand).resolves({})
|
||||||
|
s3Mock.on(DeleteObjectsCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add and then delete item
|
||||||
|
const id = await brainy.add('To be deleted', { temporary: true })
|
||||||
|
await brainy.delete(id)
|
||||||
|
|
||||||
|
// Verify delete was called
|
||||||
|
const deleteCalls = s3Mock.commandCalls(DeleteObjectCommand)
|
||||||
|
expect(deleteCalls.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should batch delete operations for efficiency', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(DeleteObjectsCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Clear all data
|
||||||
|
await brainy.clear()
|
||||||
|
|
||||||
|
// Verify batch delete was used
|
||||||
|
const batchDeleteCalls = s3Mock.commandCalls(DeleteObjectsCommand)
|
||||||
|
// Clear operation should use batch delete
|
||||||
|
expect(batchDeleteCalls.length).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
458
tests/s3-statistics-critical.test.ts
Normal file
458
tests/s3-statistics-critical.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
/**
|
||||||
|
* CRITICAL S3 Statistics Tests
|
||||||
|
*
|
||||||
|
* These tests ensure that statistics work correctly at scale with S3 storage.
|
||||||
|
* Statistics are fundamental for monitoring production deployments with millions of records.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { mockClient } from 'aws-sdk-client-mock'
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
|
DeleteObjectCommand
|
||||||
|
} from '@aws-sdk/client-s3'
|
||||||
|
import { BrainyData } from '../src/index.js'
|
||||||
|
import { S3CompatibleStorage } from '../src/storage/adapters/s3CompatibleStorage.js'
|
||||||
|
import { createMockEmbeddingFunction } from './test-utils.js'
|
||||||
|
|
||||||
|
// Create S3 mock
|
||||||
|
const s3Mock = mockClient(S3Client)
|
||||||
|
|
||||||
|
describe('CRITICAL: S3 Statistics at Scale', () => {
|
||||||
|
let brainy: BrainyData<any>
|
||||||
|
let storage: S3CompatibleStorage
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
s3Mock.reset()
|
||||||
|
vi.clearAllTimers()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.clear()
|
||||||
|
}
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Statistics Persistence and Recovery', () => {
|
||||||
|
it('should persist statistics to S3 and recover after restart', async () => {
|
||||||
|
// Setup S3 mock responses
|
||||||
|
const statisticsData = {
|
||||||
|
nounCount: { 'service-a': 1000, 'service-b': 500 },
|
||||||
|
verbCount: { 'service-a': 200, 'service-b': 100 },
|
||||||
|
metadataCount: { 'service-a': 1000, 'service-b': 500 },
|
||||||
|
hnswIndexSize: 1500,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock initial empty state
|
||||||
|
s3Mock.on(GetObjectCommand).rejectsOnce({ name: 'NoSuchKey' })
|
||||||
|
|
||||||
|
// Mock successful save
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
|
||||||
|
// Initialize Brainy with S3 storage
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data with different services
|
||||||
|
await brainy.add('Test data 1', { metadata: 'test1' }, { service: 'service-a' })
|
||||||
|
await brainy.add('Test data 2', { metadata: 'test2' }, { service: 'service-b' })
|
||||||
|
|
||||||
|
// Force statistics flush
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify statistics were saved to S3
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
expect(putCalls.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Get current statistics
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.nounCount).toBe(2)
|
||||||
|
expect(stats.serviceBreakdown).toBeDefined()
|
||||||
|
expect(stats.serviceBreakdown['service-a'].nounCount).toBe(1)
|
||||||
|
expect(stats.serviceBreakdown['service-b'].nounCount).toBe(1)
|
||||||
|
|
||||||
|
// Simulate restart by creating new instance
|
||||||
|
s3Mock.reset()
|
||||||
|
|
||||||
|
// Mock loading saved statistics
|
||||||
|
s3Mock.on(GetObjectCommand).resolves({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => JSON.stringify({
|
||||||
|
nounCount: { 'service-a': 1, 'service-b': 1 },
|
||||||
|
verbCount: { 'service-a': 0, 'service-b': 0 },
|
||||||
|
metadataCount: { 'service-a': 1, 'service-b': 1 },
|
||||||
|
hnswIndexSize: 2,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const brainy2 = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy2.init()
|
||||||
|
|
||||||
|
// Verify statistics were recovered
|
||||||
|
const recoveredStats = await brainy2.getStatistics()
|
||||||
|
expect(recoveredStats.nounCount).toBe(2)
|
||||||
|
expect(recoveredStats.serviceBreakdown['service-a'].nounCount).toBe(1)
|
||||||
|
expect(recoveredStats.serviceBreakdown['service-b'].nounCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Batching and Throttling', () => {
|
||||||
|
it('should batch statistics updates to prevent S3 rate limits', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejectsOnce({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add multiple items rapidly (simulating high load)
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
promises.push(
|
||||||
|
brainy.add(`Item ${i}`, { index: i }, { service: `service-${i % 5}` })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
// Initially, statistics shouldn't be flushed immediately
|
||||||
|
const initialPutCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
expect(initialPutCalls.length).toBeLessThan(100) // Should batch, not 100 individual calls
|
||||||
|
|
||||||
|
// Advance timers to trigger batch flush
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
|
||||||
|
// Force flush to ensure all statistics are saved
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify statistics are correct
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.nounCount).toBe(100)
|
||||||
|
|
||||||
|
// Verify batching occurred (much fewer S3 calls than items)
|
||||||
|
const finalPutCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
expect(finalPutCalls.length).toBeLessThan(20) // Should be batched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle S3 throttling (429) gracefully', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejectsOnce({ name: 'NoSuchKey' })
|
||||||
|
|
||||||
|
// Simulate throttling on first attempt, success on retry
|
||||||
|
let putAttempts = 0
|
||||||
|
s3Mock.on(PutObjectCommand).callsFake(() => {
|
||||||
|
putAttempts++
|
||||||
|
if (putAttempts === 1) {
|
||||||
|
const error: any = new Error('Too Many Requests')
|
||||||
|
error.name = 'TooManyRequestsException'
|
||||||
|
error.$metadata = { httpStatusCode: 429 }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return Promise.resolve({})
|
||||||
|
})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data
|
||||||
|
await brainy.add('Test data', { metadata: 'test' }, { service: 'throttle-test' })
|
||||||
|
|
||||||
|
// Force flush - should retry on throttling
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify retry occurred
|
||||||
|
expect(putAttempts).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Verify statistics were eventually saved
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.nounCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Time-Based Partitioning', () => {
|
||||||
|
it('should partition statistics by date to avoid single-key rate limits', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejects({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
s3Mock.on(ListObjectsV2Command).resolves({ Contents: [] })
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add data across different time periods
|
||||||
|
const today = new Date()
|
||||||
|
await brainy.add('Today item', { date: today }, { service: 'time-test' })
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Check that statistics are saved with date-based key
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
const statisticsCall = putCalls.find(call =>
|
||||||
|
call.args[0].input.Key?.includes('_system/statistics')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(statisticsCall).toBeDefined()
|
||||||
|
// Should include date in the key for partitioning
|
||||||
|
const key = statisticsCall?.args[0].input.Key
|
||||||
|
expect(key).toContain(today.toISOString().split('T')[0])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Backward Compatibility', () => {
|
||||||
|
it('should read legacy statistics format correctly', async () => {
|
||||||
|
// Mock legacy statistics format (without service breakdown)
|
||||||
|
const legacyStats = {
|
||||||
|
nounCount: 500,
|
||||||
|
verbCount: 100,
|
||||||
|
metadataCount: 500,
|
||||||
|
hnswIndexSize: 500,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).resolves({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => JSON.stringify(legacyStats)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Should handle legacy format gracefully
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
// Legacy format should be converted to new format with default service
|
||||||
|
expect(stats.nounCount).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should migrate legacy statistics to new format on write', async () => {
|
||||||
|
// Start with legacy format
|
||||||
|
const legacyStats = {
|
||||||
|
nounCount: 100,
|
||||||
|
verbCount: 50,
|
||||||
|
metadataCount: 100,
|
||||||
|
hnswIndexSize: 100,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).resolvesOnce({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => JSON.stringify(legacyStats)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Add new data
|
||||||
|
await brainy.add('New data', { metadata: 'new' }, { service: 'migration-test' })
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify new format was saved
|
||||||
|
const putCalls = s3Mock.commandCalls(PutObjectCommand)
|
||||||
|
const lastPut = putCalls[putCalls.length - 1]
|
||||||
|
const savedData = JSON.parse(lastPut.args[0].input.Body as string)
|
||||||
|
|
||||||
|
// Should have service-based structure
|
||||||
|
expect(savedData.nounCount).toBeTypeOf('object')
|
||||||
|
expect(savedData.nounCount['migration-test']).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Concurrent Updates', () => {
|
||||||
|
it('should handle concurrent statistics updates safely', async () => {
|
||||||
|
s3Mock.on(GetObjectCommand).rejectsOnce({ name: 'NoSuchKey' })
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Simulate concurrent additions from multiple services
|
||||||
|
const services = ['api', 'worker', 'batch', 'stream', 'webhook']
|
||||||
|
const concurrentOps = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const service = services[i % services.length]
|
||||||
|
concurrentOps.push(
|
||||||
|
brainy.add(`Data ${i}`, { index: i }, { service })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add relationships concurrently
|
||||||
|
await Promise.all(concurrentOps)
|
||||||
|
|
||||||
|
const ids = concurrentOps.map((_, i) => `id-${i}`)
|
||||||
|
const relationOps = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
relationOps.push(
|
||||||
|
brainy.relate(
|
||||||
|
ids[i],
|
||||||
|
ids[i + 10],
|
||||||
|
'related',
|
||||||
|
{ service: services[i % services.length] }
|
||||||
|
).catch(() => {}) // Ignore if IDs don't exist
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(relationOps)
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify all operations were counted correctly
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.nounCount).toBe(50)
|
||||||
|
|
||||||
|
// Verify per-service counts
|
||||||
|
for (const service of services) {
|
||||||
|
expect(stats.serviceBreakdown[service]).toBeDefined()
|
||||||
|
expect(stats.serviceBreakdown[service].nounCount).toBe(10) // 50 items / 5 services
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Large Scale Statistics', () => {
|
||||||
|
it('should handle statistics for millions of records efficiently', async () => {
|
||||||
|
// Mock large existing statistics
|
||||||
|
const largeStats = {
|
||||||
|
nounCount: {
|
||||||
|
'service-1': 1000000,
|
||||||
|
'service-2': 2000000,
|
||||||
|
'service-3': 1500000
|
||||||
|
},
|
||||||
|
verbCount: {
|
||||||
|
'service-1': 500000,
|
||||||
|
'service-2': 750000,
|
||||||
|
'service-3': 600000
|
||||||
|
},
|
||||||
|
metadataCount: {
|
||||||
|
'service-1': 1000000,
|
||||||
|
'service-2': 2000000,
|
||||||
|
'service-3': 1500000
|
||||||
|
},
|
||||||
|
hnswIndexSize: 4500000,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Mock.on(GetObjectCommand).resolves({
|
||||||
|
Body: {
|
||||||
|
transformToString: async () => JSON.stringify(largeStats)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({})
|
||||||
|
|
||||||
|
brainy = new BrainyData({
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
storage: {
|
||||||
|
s3Storage: {
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accessKeyId: 'test-key',
|
||||||
|
secretAccessKey: 'test-secret',
|
||||||
|
region: 'us-east-1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
// Get statistics for large dataset
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
|
||||||
|
// Verify large numbers are handled correctly
|
||||||
|
expect(stats.nounCount).toBe(4500000)
|
||||||
|
expect(stats.verbCount).toBe(1850000)
|
||||||
|
|
||||||
|
// Add more data to large dataset
|
||||||
|
await brainy.add('New item in large dataset', {}, { service: 'service-1' })
|
||||||
|
await brainy.flushStatistics()
|
||||||
|
|
||||||
|
// Verify increment worked correctly with large numbers
|
||||||
|
const updatedStats = await brainy.getStatistics()
|
||||||
|
expect(updatedStats.nounCount).toBe(4500001)
|
||||||
|
expect(updatedStats.serviceBreakdown['service-1'].nounCount).toBe(1000001)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
512
tests/s3-storage.test.ts
Normal file
512
tests/s3-storage.test.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
/**
|
||||||
|
* S3 Compatible Storage Tests
|
||||||
|
* Tests for the S3 compatible storage adapter using a simulated S3 environment
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { setupS3Mock, cleanupS3Mock, S3Commands } from './mocks/s3-mock'
|
||||||
|
import { Vector } from '../src/coreTypes'
|
||||||
|
|
||||||
|
// Setup S3 mock environment at the top level
|
||||||
|
console.log('Setting up S3 mock environment at the top level')
|
||||||
|
const s3MockSetup = setupS3Mock()
|
||||||
|
|
||||||
|
// Mock AWS SDK imports at the top level
|
||||||
|
vi.mock('@aws-sdk/client-s3', () => {
|
||||||
|
console.log('Mocking AWS SDK imports')
|
||||||
|
return {
|
||||||
|
S3Client: class MockS3Client {
|
||||||
|
send = s3MockSetup.mockS3Client.send
|
||||||
|
},
|
||||||
|
...S3Commands
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3CompatibleStorage', () => {
|
||||||
|
// Import modules inside tests to avoid issues with dynamic imports
|
||||||
|
let S3CompatibleStorage: any
|
||||||
|
let R2Storage: any
|
||||||
|
let s3Mock: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
console.log('==== TEST SETUP START ====')
|
||||||
|
|
||||||
|
// Store the mock setup for use in tests
|
||||||
|
s3Mock = s3MockSetup
|
||||||
|
|
||||||
|
// Reset the mock storage before each test
|
||||||
|
s3Mock.reset()
|
||||||
|
|
||||||
|
// Import storage factory
|
||||||
|
console.log('Importing storage factory')
|
||||||
|
const storageFactory = await import('../src/storage/storageFactory.js')
|
||||||
|
S3CompatibleStorage = storageFactory.S3CompatibleStorage
|
||||||
|
R2Storage = storageFactory.R2Storage
|
||||||
|
|
||||||
|
console.log('==== TEST SETUP COMPLETE ====')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
console.log('==== TEST CLEANUP START ====')
|
||||||
|
|
||||||
|
// Clean up S3 mock environment
|
||||||
|
cleanupS3Mock()
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
console.log('==== TEST CLEANUP COMPLETE ====')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize S3CompatibleStorage correctly', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Verify the storage was initialized correctly
|
||||||
|
expect(s3Storage).toBeDefined()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize R2Storage correctly', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const r2Storage = new R2Storage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accountId: 'test-account',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await r2Storage.init()
|
||||||
|
|
||||||
|
// Verify the storage was initialized correctly
|
||||||
|
expect(r2Storage).toBeDefined()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await r2Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should perform basic metadata operations with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Test basic metadata operations
|
||||||
|
const testMetadata = { test: 'data', value: 123 }
|
||||||
|
await s3Storage.saveMetadata('test-key', testMetadata)
|
||||||
|
|
||||||
|
const retrievedMetadata = await s3Storage.getMetadata('test-key')
|
||||||
|
expect(retrievedMetadata).toEqual(testMetadata)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle noun operations correctly with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Create test noun
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const testNoun = {
|
||||||
|
id: 'test-noun-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map([
|
||||||
|
[0, new Set(['test-noun-2', 'test-noun-3'])]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the noun
|
||||||
|
await s3Storage.saveNoun(testNoun)
|
||||||
|
|
||||||
|
// Retrieve the noun
|
||||||
|
const retrievedNoun = await s3Storage.getNoun('test-noun-1')
|
||||||
|
|
||||||
|
// Verify the noun was saved and retrieved correctly
|
||||||
|
expect(retrievedNoun).toBeDefined()
|
||||||
|
expect(retrievedNoun?.id).toBe('test-noun-1')
|
||||||
|
expect(retrievedNoun?.vector).toEqual(testVector)
|
||||||
|
|
||||||
|
// Verify connections were saved correctly
|
||||||
|
// Note: connections are stored as a Map in memory but might be serialized differently
|
||||||
|
expect(retrievedNoun?.connections).toBeDefined()
|
||||||
|
expect(retrievedNoun?.connections.get(0)).toBeDefined()
|
||||||
|
expect(retrievedNoun?.connections.get(0)?.has('test-noun-2')).toBe(true)
|
||||||
|
expect(retrievedNoun?.connections.get(0)?.has('test-noun-3')).toBe(true)
|
||||||
|
|
||||||
|
// Test getNouns with pagination
|
||||||
|
const nounsResult = await s3Storage.getNouns({ pagination: { limit: 100 } })
|
||||||
|
expect(nounsResult.items.length).toBe(1)
|
||||||
|
expect(nounsResult.items[0].id).toBe('test-noun-1')
|
||||||
|
|
||||||
|
// Test deleteNoun
|
||||||
|
await s3Storage.deleteNoun('test-noun-1')
|
||||||
|
const deletedNoun = await s3Storage.getNoun('test-noun-1')
|
||||||
|
expect(deletedNoun).toBeNull()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle verb operations correctly with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Create test verb
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const timestamp = {
|
||||||
|
seconds: Math.floor(Date.now() / 1000),
|
||||||
|
nanoseconds: (Date.now() % 1000) * 1000000
|
||||||
|
}
|
||||||
|
const testVerb = {
|
||||||
|
id: 'test-verb-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map(),
|
||||||
|
source: 'source-noun-1',
|
||||||
|
target: 'target-noun-1',
|
||||||
|
verb: 'test-relation',
|
||||||
|
weight: 0.75,
|
||||||
|
metadata: { description: 'Test relation' },
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
createdBy: {
|
||||||
|
augmentation: 'test-service',
|
||||||
|
version: '1.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the verb
|
||||||
|
await s3Storage.saveVerb(testVerb)
|
||||||
|
|
||||||
|
// Retrieve the verb
|
||||||
|
const retrievedVerb = await s3Storage.getVerb('test-verb-1')
|
||||||
|
|
||||||
|
// Verify the verb was saved and retrieved correctly
|
||||||
|
expect(retrievedVerb).toBeDefined()
|
||||||
|
expect(retrievedVerb?.id).toBe('test-verb-1')
|
||||||
|
expect(retrievedVerb?.vector).toEqual(testVector)
|
||||||
|
expect(retrievedVerb?.sourceId).toBe('source-noun-1')
|
||||||
|
expect(retrievedVerb?.targetId).toBe('target-noun-1')
|
||||||
|
expect(retrievedVerb?.type).toBe('test-relation')
|
||||||
|
expect(retrievedVerb?.weight).toBe(0.75)
|
||||||
|
expect(retrievedVerb?.metadata).toEqual({ description: 'Test relation' })
|
||||||
|
|
||||||
|
// Test getVerbs with pagination
|
||||||
|
const verbsResult = await s3Storage.getVerbs({ pagination: { limit: 100 } })
|
||||||
|
expect(verbsResult.items.length).toBe(1)
|
||||||
|
expect(verbsResult.items[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsBySource
|
||||||
|
const verbsBySource = await s3Storage.getVerbsBySource('source-noun-1')
|
||||||
|
expect(verbsBySource.length).toBe(1)
|
||||||
|
expect(verbsBySource[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsByTarget
|
||||||
|
const verbsByTarget = await s3Storage.getVerbsByTarget('target-noun-1')
|
||||||
|
expect(verbsByTarget.length).toBe(1)
|
||||||
|
expect(verbsByTarget[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test getVerbsByType
|
||||||
|
const verbsByType = await s3Storage.getVerbsByType('test-relation')
|
||||||
|
expect(verbsByType.length).toBe(1)
|
||||||
|
expect(verbsByType[0].id).toBe('test-verb-1')
|
||||||
|
|
||||||
|
// Test deleteVerb
|
||||||
|
await s3Storage.deleteVerb('test-verb-1')
|
||||||
|
const deletedVerb = await s3Storage.getVerb('test-verb-1')
|
||||||
|
expect(deletedVerb).toBeNull()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle storage status correctly with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Add some data to the storage
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const testNoun = {
|
||||||
|
id: 'test-noun-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map([
|
||||||
|
[0, new Set(['test-noun-2', 'test-noun-3'])]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
await s3Storage.saveNoun(testNoun)
|
||||||
|
await s3Storage.saveMetadata('test-key', { test: 'data', value: 123 })
|
||||||
|
|
||||||
|
// Get storage status
|
||||||
|
const status = await s3Storage.getStorageStatus()
|
||||||
|
|
||||||
|
// Verify status
|
||||||
|
expect(status.type).toBe('s3')
|
||||||
|
expect(status.used).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple objects and pagination with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Create multiple test nouns
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
const nounCount = 10
|
||||||
|
|
||||||
|
for (let i = 0; i < nounCount; i++) {
|
||||||
|
const testNoun = {
|
||||||
|
id: `test-noun-${i}`,
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map([
|
||||||
|
[0, new Set([`test-noun-${(i + 1) % nounCount}`, `test-noun-${(i + 2) % nounCount}`])]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
await s3Storage.saveNoun(testNoun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getNouns with pagination
|
||||||
|
const nounsResult = await s3Storage.getNouns({ pagination: { limit: 100 } })
|
||||||
|
expect(nounsResult.items.length).toBe(nounCount)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle change log functionality correctly with S3 storage', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Create test nouns that will generate change log entries
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
|
||||||
|
// Save first noun - should create a change log entry
|
||||||
|
const testNoun1 = {
|
||||||
|
id: 'test-noun-change-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
await s3Storage.saveNoun(testNoun1)
|
||||||
|
|
||||||
|
// Wait a bit to ensure timestamps are different
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
const firstTimestamp = Date.now()
|
||||||
|
|
||||||
|
// Wait a bit more before creating second noun
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
// Save second noun - should create another change log entry
|
||||||
|
const testNoun2 = {
|
||||||
|
id: 'test-noun-change-2',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
await s3Storage.saveNoun(testNoun2)
|
||||||
|
|
||||||
|
// Get changes since beginning of time
|
||||||
|
const allChanges = await s3Storage.getChangesSince(0)
|
||||||
|
|
||||||
|
// Verify we have at least 2 changes (might be more due to initialization)
|
||||||
|
expect(allChanges.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
// Verify the changes contain our noun operations
|
||||||
|
const nounChanges = allChanges.filter(c =>
|
||||||
|
c.entityType === 'noun' &&
|
||||||
|
(c.entityId === 'test-noun-change-1' || c.entityId === 'test-noun-change-2')
|
||||||
|
)
|
||||||
|
expect(nounChanges.length).toBe(2)
|
||||||
|
|
||||||
|
// Get changes since first timestamp - should only include the second noun
|
||||||
|
const recentChanges = await s3Storage.getChangesSince(firstTimestamp)
|
||||||
|
const recentNounChanges = recentChanges.filter(c =>
|
||||||
|
c.entityType === 'noun' && c.entityId === 'test-noun-change-2'
|
||||||
|
)
|
||||||
|
expect(recentNounChanges.length).toBe(1)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle change log functionality with getChangesSince', async () => {
|
||||||
|
// Create the bucket first using our mock
|
||||||
|
const createBucketCommand = new S3Commands.CreateBucketCommand({
|
||||||
|
Bucket: 'test-bucket'
|
||||||
|
})
|
||||||
|
await s3Mock.mockS3Client.send(createBucketCommand)
|
||||||
|
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const s3Storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize the storage
|
||||||
|
await s3Storage.init()
|
||||||
|
|
||||||
|
// Create test vector
|
||||||
|
const testVector: Vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
|
||||||
|
// Create first noun (older entry)
|
||||||
|
const testNoun1 = {
|
||||||
|
id: 'test-noun-cleanup-1',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
await s3Storage.saveNoun(testNoun1)
|
||||||
|
|
||||||
|
// Record the timestamp after first noun
|
||||||
|
const oldTimestamp = Date.now()
|
||||||
|
|
||||||
|
// Wait to ensure timestamps are different
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// Create second noun (newer entry)
|
||||||
|
const testNoun2 = {
|
||||||
|
id: 'test-noun-cleanup-2',
|
||||||
|
vector: testVector,
|
||||||
|
connections: new Map()
|
||||||
|
}
|
||||||
|
await s3Storage.saveNoun(testNoun2)
|
||||||
|
|
||||||
|
// Verify we have both change log entries
|
||||||
|
const allChanges = await s3Storage.getChangesSince(0)
|
||||||
|
const testNounChanges = allChanges.filter(c =>
|
||||||
|
c.entityType === 'noun' &&
|
||||||
|
(c.entityId === 'test-noun-cleanup-1' || c.entityId === 'test-noun-cleanup-2')
|
||||||
|
)
|
||||||
|
expect(testNounChanges.length).toBe(2)
|
||||||
|
|
||||||
|
// Get changes since oldTimestamp - should only include the second noun
|
||||||
|
const recentChanges = await s3Storage.getChangesSince(oldTimestamp)
|
||||||
|
const recentNounChanges = recentChanges.filter(c =>
|
||||||
|
c.entityType === 'noun' &&
|
||||||
|
(c.entityId === 'test-noun-cleanup-1' || c.entityId === 'test-noun-cleanup-2')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should only have the newer entry for test-noun-cleanup-2
|
||||||
|
expect(recentNounChanges.length).toBe(1)
|
||||||
|
expect(recentNounChanges[0].entityId).toBe('test-noun-cleanup-2')
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await s3Storage.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
411
tests/service-statistics.test.ts
Normal file
411
tests/service-statistics.test.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
/**
|
||||||
|
* Tests for per-service statistics tracking functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { ServiceStatistics } from '../src/coreTypes.js'
|
||||||
|
|
||||||
|
describe('Per-Service Statistics', () => {
|
||||||
|
let brainy: BrainyData<any>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a new instance with a default service
|
||||||
|
brainy = new BrainyData({
|
||||||
|
defaultService: 'test-service',
|
||||||
|
storage: {
|
||||||
|
forceMemoryStorage: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Cleanup
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Service Tracking', () => {
|
||||||
|
it('should track data by default service', async () => {
|
||||||
|
// Add data using default service
|
||||||
|
await brainy.add({ content: 'test data 1' }, { noun: 'test' })
|
||||||
|
await brainy.add({ content: 'test data 2' }, { noun: 'test' })
|
||||||
|
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.serviceBreakdown).toBeDefined()
|
||||||
|
expect(stats.serviceBreakdown?.['test-service']).toBeDefined()
|
||||||
|
expect(stats.serviceBreakdown?.['test-service'].nounCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track data by explicit service', async () => {
|
||||||
|
// Add data with explicit service
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'github data' },
|
||||||
|
{ noun: 'repository' },
|
||||||
|
{ service: 'github-package' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'bluesky data' },
|
||||||
|
{ noun: 'post' },
|
||||||
|
{ service: 'bluesky-package' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.serviceBreakdown?.['github-package'].nounCount).toBe(1)
|
||||||
|
expect(stats.serviceBreakdown?.['bluesky-package'].nounCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track verbs by service', async () => {
|
||||||
|
// Add nouns first
|
||||||
|
const id1 = await brainy.add(
|
||||||
|
{ content: 'user 1' },
|
||||||
|
{ noun: 'Person' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const id2 = await brainy.add(
|
||||||
|
{ content: 'user 2' },
|
||||||
|
{ noun: 'Person' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create relationship
|
||||||
|
await brainy.relate(
|
||||||
|
id1,
|
||||||
|
id2,
|
||||||
|
{ verb: 'follows' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = await brainy.getStatistics()
|
||||||
|
expect(stats.serviceBreakdown?.['social-service'].verbCount).toBe(1)
|
||||||
|
expect(stats.serviceBreakdown?.['social-service'].nounCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listServices()', () => {
|
||||||
|
it('should list all services that have written data', async () => {
|
||||||
|
// Add data from multiple services
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 1' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-a' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 2' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-b' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 3' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-a' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const services = await brainy.listServices()
|
||||||
|
|
||||||
|
expect(services).toHaveLength(2)
|
||||||
|
expect(services.map(s => s.name)).toContain('service-a')
|
||||||
|
expect(services.map(s => s.name)).toContain('service-b')
|
||||||
|
|
||||||
|
const serviceA = services.find(s => s.name === 'service-a')
|
||||||
|
expect(serviceA?.totalNouns).toBe(2)
|
||||||
|
|
||||||
|
const serviceB = services.find(s => s.name === 'service-b')
|
||||||
|
expect(serviceB?.totalNouns).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include service activity timestamps', async () => {
|
||||||
|
const beforeAdd = new Date()
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'test data' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'timestamped-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const afterAdd = new Date()
|
||||||
|
|
||||||
|
const services = await brainy.listServices()
|
||||||
|
const service = services.find(s => s.name === 'timestamped-service')
|
||||||
|
|
||||||
|
expect(service).toBeDefined()
|
||||||
|
expect(service?.status).toBe('active')
|
||||||
|
|
||||||
|
// Check if timestamps are present (they may not be if the storage adapter doesn't support them)
|
||||||
|
if (service?.lastActivity) {
|
||||||
|
const lastActivityTime = new Date(service.lastActivity).getTime()
|
||||||
|
expect(lastActivityTime).toBeGreaterThanOrEqual(beforeAdd.getTime())
|
||||||
|
expect(lastActivityTime).toBeLessThanOrEqual(afterAdd.getTime())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should determine service status correctly', async () => {
|
||||||
|
// Add data from an active service
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'recent data' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'active-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const services = await brainy.listServices()
|
||||||
|
const activeService = services.find(s => s.name === 'active-service')
|
||||||
|
|
||||||
|
// Should be marked as active if it has recent activity
|
||||||
|
expect(activeService?.status).toBe('active')
|
||||||
|
|
||||||
|
// Service with no write operations should be marked as read-only
|
||||||
|
// This would need to be tested with a service that only reads
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getServiceStatistics()', () => {
|
||||||
|
it('should return statistics for a specific service', async () => {
|
||||||
|
// Add data for specific service
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 1' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'target-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 2' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'target-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 3' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'other-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const serviceStats = await brainy.getServiceStatistics('target-service')
|
||||||
|
|
||||||
|
expect(serviceStats).toBeDefined()
|
||||||
|
expect(serviceStats?.name).toBe('target-service')
|
||||||
|
expect(serviceStats?.totalNouns).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for non-existent service', async () => {
|
||||||
|
const serviceStats = await brainy.getServiceStatistics('non-existent')
|
||||||
|
expect(serviceStats).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include operation counts when available', async () => {
|
||||||
|
// Add multiple operations
|
||||||
|
const id = await brainy.add(
|
||||||
|
{ content: 'data' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'ops-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.updateMetadata(
|
||||||
|
id,
|
||||||
|
{ content: 'updated data', noun: 'Document' },
|
||||||
|
{ service: 'ops-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const serviceStats = await brainy.getServiceStatistics('ops-service')
|
||||||
|
|
||||||
|
expect(serviceStats).toBeDefined()
|
||||||
|
expect(serviceStats?.totalNouns).toBe(1)
|
||||||
|
expect(serviceStats?.totalMetadata).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
// Operations tracking depends on whether the storage adapter tracks them
|
||||||
|
if (serviceStats?.operations) {
|
||||||
|
expect(serviceStats.operations.adds).toBeGreaterThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Service-Filtered getStatistics()', () => {
|
||||||
|
it('should filter statistics by single service', async () => {
|
||||||
|
// Add data from multiple services
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 1' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-a' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 2' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-b' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 3' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-a' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const statsA = await brainy.getStatistics({ service: 'service-a' })
|
||||||
|
expect(statsA.nounCount).toBe(2)
|
||||||
|
|
||||||
|
const statsB = await brainy.getStatistics({ service: 'service-b' })
|
||||||
|
expect(statsB.nounCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter statistics by multiple services', async () => {
|
||||||
|
// Add data from multiple services
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 1' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-a' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 2' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-b' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'data 3' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'service-c' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = await brainy.getStatistics({
|
||||||
|
service: ['service-a', 'service-b']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(stats.nounCount).toBe(2)
|
||||||
|
expect(stats.serviceBreakdown?.['service-a'].nounCount).toBe(1)
|
||||||
|
expect(stats.serviceBreakdown?.['service-b'].nounCount).toBe(1)
|
||||||
|
expect(stats.serviceBreakdown?.['service-c']).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Service-Filtered Queries', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add test data from different services
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'github repository data' },
|
||||||
|
{ noun: 'Repository', createdBy: { augmentation: 'github-service' } },
|
||||||
|
{ service: 'github-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'bluesky post data' },
|
||||||
|
{ noun: 'Post', createdBy: { augmentation: 'bluesky-service' } },
|
||||||
|
{ service: 'bluesky-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.add(
|
||||||
|
{ content: 'another github repository' },
|
||||||
|
{ noun: 'Repository', createdBy: { augmentation: 'github-service' } },
|
||||||
|
{ service: 'github-service' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter search results by service', async () => {
|
||||||
|
const results = await brainy.search('repository', 10, {
|
||||||
|
service: 'github-service'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should only return results from github-service
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
results.forEach(result => {
|
||||||
|
expect(result.metadata?.createdBy?.augmentation).toBe('github-service')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter getNouns by service', async () => {
|
||||||
|
const result = await brainy.getNouns({
|
||||||
|
filter: {
|
||||||
|
service: 'github-service'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Service filtering in getNouns depends on storage adapter implementation
|
||||||
|
// The test verifies the API works but actual filtering may vary
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.items).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter getVerbs by service', async () => {
|
||||||
|
// Add verbs from different services
|
||||||
|
const id1 = await brainy.add(
|
||||||
|
{ content: 'user 1' },
|
||||||
|
{ noun: 'Person' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const id2 = await brainy.add(
|
||||||
|
{ content: 'user 2' },
|
||||||
|
{ noun: 'Person' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
await brainy.relate(
|
||||||
|
id1,
|
||||||
|
id2,
|
||||||
|
{ verb: 'follows' },
|
||||||
|
{ service: 'social-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await brainy.getVerbs({
|
||||||
|
filter: {
|
||||||
|
service: 'social-service'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Service filtering in getVerbs depends on storage adapter implementation
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.items).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Service Metadata on Data', () => {
|
||||||
|
it('should add service metadata to nouns', async () => {
|
||||||
|
const id = await brainy.add(
|
||||||
|
{ content: 'test data' },
|
||||||
|
{ noun: 'Document' },
|
||||||
|
{ service: 'metadata-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const doc = await brainy.get(id)
|
||||||
|
expect(doc).toBeDefined()
|
||||||
|
|
||||||
|
// Service tracking is done in statistics, not directly in metadata
|
||||||
|
// Verify through statistics instead
|
||||||
|
const stats = await brainy.getStatistics({ service: 'metadata-service' })
|
||||||
|
expect(stats.nounCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track service for verbs', async () => {
|
||||||
|
const id1 = await brainy.add(
|
||||||
|
{ content: 'node 1' },
|
||||||
|
{ noun: 'Node' },
|
||||||
|
{ service: 'graph-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const id2 = await brainy.add(
|
||||||
|
{ content: 'node 2' },
|
||||||
|
{ noun: 'Node' },
|
||||||
|
{ service: 'graph-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const verbId = await brainy.relate(
|
||||||
|
id1,
|
||||||
|
id2,
|
||||||
|
{ verb: 'connects' },
|
||||||
|
{ service: 'graph-service' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(verbId).toBeDefined()
|
||||||
|
|
||||||
|
// Verify through statistics
|
||||||
|
const stats = await brainy.getStatistics({ service: 'graph-service' })
|
||||||
|
expect(stats.verbCount).toBe(1)
|
||||||
|
expect(stats.nounCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
90
tests/setup.ts
Normal file
90
tests/setup.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Simple test setup for Brainy library
|
||||||
|
* No direct TensorFlow references - patches are handled internally by Brainy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, afterEach, afterAll } from 'vitest'
|
||||||
|
import { existsSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// Define the test utilities type for reuse
|
||||||
|
type TestUtilsType = {
|
||||||
|
createTestVector: (dimensions: number) => number[]
|
||||||
|
timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend global type definitions for both global and globalThis
|
||||||
|
declare global {
|
||||||
|
let testUtils: TestUtilsType | undefined
|
||||||
|
let __ENV__: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly declare globalThis interface to ensure TypeScript recognizes these properties
|
||||||
|
declare global {
|
||||||
|
interface globalThis {
|
||||||
|
testUtils?: TestUtilsType | undefined
|
||||||
|
__ENV__?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up between tests
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear any global state that might interfere with tests
|
||||||
|
if (typeof globalThis !== 'undefined' && globalThis.__ENV__) {
|
||||||
|
delete globalThis.__ENV__
|
||||||
|
}
|
||||||
|
if (typeof global !== 'undefined' && global.__ENV__) {
|
||||||
|
delete global.__ENV__
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up test data directory to prevent file accumulation
|
||||||
|
const testDataDir = join(process.cwd(), 'brainy-data')
|
||||||
|
if (existsSync(testDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(testDataDir, { recursive: true, force: true })
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up after each test
|
||||||
|
afterEach(() => {
|
||||||
|
// Force garbage collection if available (requires --expose-gc flag)
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Final cleanup after all tests
|
||||||
|
afterAll(() => {
|
||||||
|
// Clean up test data directory
|
||||||
|
const testDataDir = join(process.cwd(), 'brainy-data')
|
||||||
|
if (existsSync(testDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(testDataDir, { recursive: true, force: true })
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add simple test utilities to both global and globalThis for compatibility
|
||||||
|
const testUtilsObject = {
|
||||||
|
// Create a simple test vector with predictable values
|
||||||
|
createTestVector: (dimensions: number): number[] => {
|
||||||
|
return Array.from({ length: dimensions }, (_, i) => (i + 1) / dimensions)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Standard timeout for async operations
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
global.testUtils = testUtilsObject
|
||||||
|
globalThis.testUtils = testUtilsObject
|
||||||
|
|
||||||
|
// Set a clear test environment flag for embedding system
|
||||||
|
globalThis.__BRAINY_TEST_ENV__ = true
|
||||||
|
if (typeof global !== 'undefined') {
|
||||||
|
(global as any).__BRAINY_TEST_ENV__ = true
|
||||||
|
}
|
||||||
51
tests/simple-metadata-test.ts
Normal file
51
tests/simple-metadata-test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Simple standalone test to check metadata filtering
|
||||||
|
import { BrainyData } from '../dist/brainyData.js'
|
||||||
|
|
||||||
|
async function testMetadataFiltering() {
|
||||||
|
console.log('Creating BrainyData instance...')
|
||||||
|
const brainy = new BrainyData({
|
||||||
|
storage: { forceMemoryStorage: true },
|
||||||
|
hnsw: { M: 4, efConstruction: 20 },
|
||||||
|
logging: { verbose: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Initializing...')
|
||||||
|
await brainy.init()
|
||||||
|
|
||||||
|
console.log('Adding test data...')
|
||||||
|
await brainy.add('Senior developer Alice', { level: 'senior', name: 'Alice' })
|
||||||
|
await brainy.add('Junior developer Bob', { level: 'junior', name: 'Bob' })
|
||||||
|
|
||||||
|
console.log('Searching without filter...')
|
||||||
|
const allResults = await brainy.searchText('developer', 10)
|
||||||
|
console.log('All results:', allResults.map(r => ({
|
||||||
|
metadata: r.metadata,
|
||||||
|
score: r.score.toFixed(3)
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Check if metadata index is available
|
||||||
|
console.log('Metadata index available?', !!brainy.metadataIndex)
|
||||||
|
|
||||||
|
console.log('Searching with metadata filter...')
|
||||||
|
const seniorResults = await brainy.searchText('developer', 10, {
|
||||||
|
metadata: { level: 'senior' }
|
||||||
|
})
|
||||||
|
console.log('Senior results:', seniorResults.map(r => ({
|
||||||
|
metadata: r.metadata,
|
||||||
|
score: r.score.toFixed(3)
|
||||||
|
})))
|
||||||
|
|
||||||
|
if (brainy.metadataIndex) {
|
||||||
|
console.log('Checking metadata index for level:senior...')
|
||||||
|
const levelSeniorIds = await brainy.metadataIndex.getIds('level', 'senior')
|
||||||
|
console.log('IDs with level=senior from index:', levelSeniorIds)
|
||||||
|
|
||||||
|
const levelJuniorIds = await brainy.metadataIndex.getIds('level', 'junior')
|
||||||
|
console.log('IDs with level=junior from index:', levelJuniorIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nResults: All=${allResults.length}, Senior=${seniorResults.length}`)
|
||||||
|
console.log('Filter working?', seniorResults.length < allResults.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
testMetadataFiltering().catch(console.error)
|
||||||
440
tests/specialized-scenarios.test.ts
Normal file
440
tests/specialized-scenarios.test.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
/**
|
||||||
|
* Specialized Scenarios Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite verifies that Brainy handles specialized scenarios correctly:
|
||||||
|
* 1. Read-only mode enforcement
|
||||||
|
* 2. Relationship operations (relate, findSimilar)
|
||||||
|
* 3. Metadata handling in add/relate operations
|
||||||
|
* 4. Statistics and monitoring functionality
|
||||||
|
*
|
||||||
|
* These tests ensure that advanced features work as expected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData, createStorage } from '../dist/unified.js'
|
||||||
|
|
||||||
|
describe('Specialized Scenarios Tests', () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a test BrainyData instance with memory storage for faster tests
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data to ensure a clean test environment
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up after each test
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Read-Only Mode', () => {
|
||||||
|
it('should enforce read-only mode for all write operations', async () => {
|
||||||
|
// Add some initial data
|
||||||
|
const id1 = await brainyInstance.add('test item 1')
|
||||||
|
const id2 = await brainyInstance.add('test item 2')
|
||||||
|
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
expect(brainyInstance.isReadOnly()).toBe(true)
|
||||||
|
|
||||||
|
// Test all write operations
|
||||||
|
await expect(brainyInstance.add('new item')).rejects.toThrow(/read-only/i)
|
||||||
|
await expect(
|
||||||
|
brainyInstance.addBatch(['batch item 1', 'batch item 2'])
|
||||||
|
).rejects.toThrow(/read-only/i)
|
||||||
|
await expect(brainyInstance.delete(id1)).rejects.toThrow(/read-only/i)
|
||||||
|
await expect(
|
||||||
|
brainyInstance.updateMetadata(id1, { updated: true })
|
||||||
|
).rejects.toThrow(/read-only/i)
|
||||||
|
await expect(
|
||||||
|
brainyInstance.relate(id1, id2, 'test-relation')
|
||||||
|
).rejects.toThrow(/read-only/i)
|
||||||
|
await expect(brainyInstance.clear()).rejects.toThrow(/read-only/i)
|
||||||
|
|
||||||
|
// Read operations should still work
|
||||||
|
const item = await brainyInstance.get(id1)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
|
||||||
|
const searchResults = await brainyInstance.search('test', 5)
|
||||||
|
expect(searchResults.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
expect(brainyInstance.isReadOnly()).toBe(false)
|
||||||
|
|
||||||
|
// Now write operations should work
|
||||||
|
const id3 = await brainyInstance.add('new item after reset')
|
||||||
|
expect(id3).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow setting read-only mode during initialization', async () => {
|
||||||
|
// Create a new instance with read-only mode
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
const readOnlyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage,
|
||||||
|
readOnly: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await readOnlyInstance.init()
|
||||||
|
|
||||||
|
// Verify it's in read-only mode
|
||||||
|
expect(readOnlyInstance.isReadOnly()).toBe(true)
|
||||||
|
|
||||||
|
// Write operations should fail
|
||||||
|
await expect(readOnlyInstance.add('test item')).rejects.toThrow(
|
||||||
|
/read-only/i
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await readOnlyInstance.shutDown()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Relationship Operations', () => {
|
||||||
|
it('should create and query relationships between items', async () => {
|
||||||
|
// Add some items
|
||||||
|
const id1 = await brainyInstance.add('source item', { type: 'source' })
|
||||||
|
const id2 = await brainyInstance.add('target item', { type: 'target' })
|
||||||
|
|
||||||
|
// Create relationship
|
||||||
|
await brainyInstance.relate(id1, id2, 'test-relation', { strength: 0.9 })
|
||||||
|
|
||||||
|
// Find similar items
|
||||||
|
const similarItems = await brainyInstance.findSimilar(id1)
|
||||||
|
expect(similarItems.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// The target item should be in the results
|
||||||
|
const foundTarget = similarItems.some((item) => item.id === id2)
|
||||||
|
expect(foundTarget).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple relationship types', async () => {
|
||||||
|
// Add some items
|
||||||
|
const person1 = await brainyInstance.add('Alice', { type: 'person' })
|
||||||
|
const person2 = await brainyInstance.add('Bob', { type: 'person' })
|
||||||
|
const company = await brainyInstance.add('Acme Corp', { type: 'company' })
|
||||||
|
|
||||||
|
// Create different relationship types
|
||||||
|
await brainyInstance.relate(person1, person2, 'friend-of', {
|
||||||
|
since: '2020'
|
||||||
|
})
|
||||||
|
await brainyInstance.relate(person1, company, 'works-at', {
|
||||||
|
position: 'Manager'
|
||||||
|
})
|
||||||
|
await brainyInstance.relate(person2, company, 'works-at', {
|
||||||
|
position: 'Developer'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Instead of using findSimilar with filtering, directly get the related entities
|
||||||
|
// Get all verbs from person1
|
||||||
|
const outgoingVerbs = await (
|
||||||
|
brainyInstance as any
|
||||||
|
).storage.getVerbsBySource(person1)
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log(
|
||||||
|
'DEBUG: All outgoing verbs from person1:',
|
||||||
|
JSON.stringify(
|
||||||
|
outgoingVerbs,
|
||||||
|
(key, value) => {
|
||||||
|
if (key === 'connections' && value instanceof Map) {
|
||||||
|
return '[Map]'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter friend-of relationships
|
||||||
|
const friendOfVerbs = outgoingVerbs.filter(
|
||||||
|
(verb) => verb.verb === 'friend-of'
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
'DEBUG: Filtered friend-of verbs:',
|
||||||
|
JSON.stringify(
|
||||||
|
friendOfVerbs,
|
||||||
|
(key, value) => {
|
||||||
|
if (key === 'connections' && value instanceof Map) {
|
||||||
|
return '[Map]'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(friendOfVerbs.length).toBe(1)
|
||||||
|
expect(friendOfVerbs[0].target).toBe(person2)
|
||||||
|
|
||||||
|
// Filter works-at relationships
|
||||||
|
const worksAtVerbs = outgoingVerbs.filter(
|
||||||
|
(verb) => verb.verb === 'works-at'
|
||||||
|
)
|
||||||
|
expect(worksAtVerbs.length).toBe(1)
|
||||||
|
expect(worksAtVerbs[0].target).toBe(company)
|
||||||
|
|
||||||
|
// Get all verbs to company
|
||||||
|
const incomingToCompany = await (
|
||||||
|
brainyInstance as any
|
||||||
|
).storage.getVerbsByTarget(company)
|
||||||
|
expect(incomingToCompany.length).toBe(2) // Both person1 and person2 work at company
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle bidirectional relationships', async () => {
|
||||||
|
// Add some items
|
||||||
|
const item1 = await brainyInstance.add('item 1')
|
||||||
|
const item2 = await brainyInstance.add('item 2')
|
||||||
|
|
||||||
|
// Create bidirectional relationships
|
||||||
|
await brainyInstance.relate(item1, item2, 'connected-to')
|
||||||
|
await brainyInstance.relate(item2, item1, 'connected-to')
|
||||||
|
|
||||||
|
// Directly check the relationships instead of using findSimilar
|
||||||
|
// Get outgoing relationships from item1
|
||||||
|
const outgoingFromItem1 = await (
|
||||||
|
brainyInstance as any
|
||||||
|
).storage.getVerbsBySource(item1)
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log(
|
||||||
|
'DEBUG: All outgoing verbs from item1:',
|
||||||
|
JSON.stringify(
|
||||||
|
outgoingFromItem1,
|
||||||
|
(key, value) => {
|
||||||
|
if (key === 'connections' && value instanceof Map) {
|
||||||
|
return '[Map]'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(outgoingFromItem1.length).toBe(1)
|
||||||
|
expect(outgoingFromItem1[0].target).toBe(item2)
|
||||||
|
console.log(
|
||||||
|
'DEBUG: outgoingFromItem1[0]:',
|
||||||
|
JSON.stringify(
|
||||||
|
outgoingFromItem1[0],
|
||||||
|
(key, value) => {
|
||||||
|
if (key === 'connections' && value instanceof Map) {
|
||||||
|
return '[Map]'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(outgoingFromItem1[0].verb).toBe('connected-to')
|
||||||
|
|
||||||
|
// Get outgoing relationships from item2
|
||||||
|
const outgoingFromItem2 = await (
|
||||||
|
brainyInstance as any
|
||||||
|
).storage.getVerbsBySource(item2)
|
||||||
|
expect(outgoingFromItem2.length).toBe(1)
|
||||||
|
expect(outgoingFromItem2[0].target).toBe(item1)
|
||||||
|
expect(outgoingFromItem2[0].verb).toBe('connected-to')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Metadata Handling', () => {
|
||||||
|
it('should store and retrieve metadata in add operations', async () => {
|
||||||
|
// Add item with complex metadata
|
||||||
|
const metadata = {
|
||||||
|
title: 'Test Document',
|
||||||
|
tags: ['test', 'document', 'metadata'],
|
||||||
|
author: {
|
||||||
|
name: 'Test Author',
|
||||||
|
email: 'test@example.com'
|
||||||
|
},
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
version: 1.0,
|
||||||
|
isPublic: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await brainyInstance.add('test content', metadata)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Retrieve the item and verify metadata
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
|
||||||
|
// Instead of expecting exact equality, check individual properties
|
||||||
|
// This allows for the ID to be present in the metadata
|
||||||
|
expect(item.metadata.title).toBe('Test Document')
|
||||||
|
expect(item.metadata.tags).toEqual(['test', 'document', 'metadata'])
|
||||||
|
expect(item.metadata.author.name).toBe('Test Author')
|
||||||
|
expect(item.metadata.isPublic).toBe(true)
|
||||||
|
expect(item.metadata.created).toBe(metadata.created)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store and retrieve metadata in relate operations', async () => {
|
||||||
|
// Add items
|
||||||
|
const id1 = await brainyInstance.add('source item')
|
||||||
|
const id2 = await brainyInstance.add('target item')
|
||||||
|
|
||||||
|
// Create relationship with metadata
|
||||||
|
const relationMetadata = {
|
||||||
|
strength: 0.95,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
bidirectional: true,
|
||||||
|
properties: {
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await brainyInstance.relate(id1, id2, 'test-relation', relationMetadata)
|
||||||
|
|
||||||
|
// Find similar items
|
||||||
|
const similarItems = await brainyInstance.findSimilar(id1)
|
||||||
|
expect(similarItems.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// The exact structure of the results depends on the implementation
|
||||||
|
// but we should at least find the target item
|
||||||
|
const targetItem = similarItems.find((item) => item.id === id2)
|
||||||
|
expect(targetItem).toBeDefined()
|
||||||
|
|
||||||
|
// The relationship metadata might be accessible through the result
|
||||||
|
// This depends on the specific implementation of findSimilar
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update metadata correctly', async () => {
|
||||||
|
// Add item with initial metadata
|
||||||
|
const id = await brainyInstance.add('test content', {
|
||||||
|
title: 'Initial Title',
|
||||||
|
count: 1,
|
||||||
|
tags: ['initial']
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await brainyInstance.updateMetadata(id, {
|
||||||
|
title: 'Updated Title',
|
||||||
|
count: 2,
|
||||||
|
tags: ['updated', 'metadata'],
|
||||||
|
newField: 'new value'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retrieve the item and verify updated metadata
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item.metadata.title).toBe('Updated Title')
|
||||||
|
expect(item.metadata.count).toBe(2)
|
||||||
|
expect(item.metadata.tags).toEqual(['updated', 'metadata'])
|
||||||
|
expect(item.metadata.newField).toBe('new value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle metadata in search results', async () => {
|
||||||
|
// Add items with metadata
|
||||||
|
await brainyInstance.add('apple banana', { fruit: true, color: 'yellow' })
|
||||||
|
await brainyInstance.add('apple orange', { fruit: true, color: 'orange' })
|
||||||
|
|
||||||
|
// Search for items
|
||||||
|
const results = await brainyInstance.search('apple', 5)
|
||||||
|
expect(results.length).toBe(2)
|
||||||
|
|
||||||
|
// Verify metadata in results
|
||||||
|
results.forEach((result) => {
|
||||||
|
expect(result.metadata).toBeDefined()
|
||||||
|
expect(result.metadata.fruit).toBe(true)
|
||||||
|
expect(['yellow', 'orange']).toContain(result.metadata.color)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Statistics and Monitoring', () => {
|
||||||
|
it('should track and report statistics', async () => {
|
||||||
|
// Add some data
|
||||||
|
await brainyInstance.add('stats test 1')
|
||||||
|
await brainyInstance.add('stats test 2')
|
||||||
|
await brainyInstance.add('stats test 3')
|
||||||
|
|
||||||
|
// Perform some searches
|
||||||
|
await brainyInstance.search('stats', 5)
|
||||||
|
await brainyInstance.search('test', 5)
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
const stats = await brainyInstance.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
|
||||||
|
// Verify noun statistics
|
||||||
|
expect(stats.nouns).toBeDefined()
|
||||||
|
expect(stats.nouns.count).toBe(3)
|
||||||
|
|
||||||
|
// Instead of expecting operations to be defined, check specific properties
|
||||||
|
// that we know should exist in the statistics
|
||||||
|
expect(stats.nounCount).toBe(3)
|
||||||
|
expect(stats.hnswIndexSize).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should flush statistics', async () => {
|
||||||
|
// Add some data
|
||||||
|
await brainyInstance.add('stats test 1')
|
||||||
|
await brainyInstance.add('stats test 2')
|
||||||
|
|
||||||
|
// Get statistics before flush
|
||||||
|
const statsBefore = await brainyInstance.getStatistics()
|
||||||
|
expect(statsBefore.nouns.count).toBe(2)
|
||||||
|
|
||||||
|
// Flush statistics
|
||||||
|
await brainyInstance.flushStatistics()
|
||||||
|
|
||||||
|
// Get statistics after flush
|
||||||
|
const statsAfter = await brainyInstance.getStatistics()
|
||||||
|
|
||||||
|
// The noun count should remain the same
|
||||||
|
expect(statsAfter.nouns.count).toBe(2)
|
||||||
|
|
||||||
|
// But operation counts might be reset
|
||||||
|
// This depends on the specific implementation
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track database size', async () => {
|
||||||
|
// Add some data and store the IDs
|
||||||
|
const id1 = await brainyInstance.add('size test 1')
|
||||||
|
const id2 = await brainyInstance.add('size test 2')
|
||||||
|
console.log(`Added items with IDs: ${id1}, ${id2}`)
|
||||||
|
|
||||||
|
// Get database size
|
||||||
|
const size = await brainyInstance.size()
|
||||||
|
expect(size).toBe(2)
|
||||||
|
console.log(`Initial size: ${size}`)
|
||||||
|
|
||||||
|
// Add more data
|
||||||
|
const id3 = await brainyInstance.add('size test 3')
|
||||||
|
console.log(`Added third item with ID: ${id3}`)
|
||||||
|
|
||||||
|
// Size should increase
|
||||||
|
const newSize = await brainyInstance.size()
|
||||||
|
expect(newSize).toBe(3)
|
||||||
|
console.log(`Size after adding third item: ${newSize}`)
|
||||||
|
|
||||||
|
// Get all nouns to see what's in the index
|
||||||
|
const nouns = brainyInstance.index.getNouns()
|
||||||
|
console.log(`Nouns in index: ${nouns.size}`)
|
||||||
|
for (const [id, noun] of nouns.entries()) {
|
||||||
|
console.log(`Noun ${id}: text=${noun.text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete by actual ID instead of content
|
||||||
|
console.log(`Deleting item with ID: ${id3}`)
|
||||||
|
await brainyInstance.delete(id3)
|
||||||
|
|
||||||
|
// Size should decrease
|
||||||
|
const finalSize = await brainyInstance.size()
|
||||||
|
console.log(`Final size: ${finalSize}`)
|
||||||
|
expect(finalSize).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
158
tests/statistics-storage.test.ts
Normal file
158
tests/statistics-storage.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
/**
|
||||||
|
* Test script for the statistics storage implementation
|
||||||
|
*
|
||||||
|
* This script tests:
|
||||||
|
* 1. Saving statistics data
|
||||||
|
* 2. Retrieving statistics data
|
||||||
|
* 3. Verifying that the data is correctly saved and retrieved
|
||||||
|
* 4. Checking that time-based partitioning works correctly
|
||||||
|
* 5. Checking that backward compatibility is maintained
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import required modules
|
||||||
|
// @ts-expect-error - dotenv doesn't have TypeScript types
|
||||||
|
import { config } from 'dotenv'
|
||||||
|
import { setTimeout } from 'timers/promises'
|
||||||
|
import { describe, it, expect, beforeAll, beforeEach } from 'vitest'
|
||||||
|
import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||||
|
import * as process from 'process'
|
||||||
|
|
||||||
|
// Define types for statistics data
|
||||||
|
interface ServiceStatistics {
|
||||||
|
nounCount: number
|
||||||
|
verbCount: number
|
||||||
|
metadataCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatisticsData {
|
||||||
|
nounCount: Record<string, number>
|
||||||
|
verbCount: Record<string, number>
|
||||||
|
metadataCount: Record<string, number>
|
||||||
|
hnswIndexSize: number
|
||||||
|
lastUpdated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define types for storage configuration
|
||||||
|
interface S3StorageConfig {
|
||||||
|
endpoint: string
|
||||||
|
region: string
|
||||||
|
bucketName: string
|
||||||
|
accessKeyId: string
|
||||||
|
secretAccessKey: string
|
||||||
|
prefix: string
|
||||||
|
serviceType?: string
|
||||||
|
sessionToken?: string
|
||||||
|
accountId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
config()
|
||||||
|
|
||||||
|
// Create test statistics data
|
||||||
|
const testStatistics: StatisticsData = {
|
||||||
|
nounCount: { 'test-service': 100, 'another-service': 50 },
|
||||||
|
verbCount: { 'test-service': 75, 'another-service': 25 },
|
||||||
|
metadataCount: { 'test-service': 100, 'another-service': 50 },
|
||||||
|
hnswIndexSize: 150,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const storageConfig: S3StorageConfig = {
|
||||||
|
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
bucketName: process.env.S3_BUCKET || 'test-bucket',
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||||
|
prefix: 'test-statistics/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if required S3 credentials are available
|
||||||
|
const hasS3Credentials = !!process.env.S3_ACCESS_KEY && !!process.env.S3_SECRET_KEY;
|
||||||
|
|
||||||
|
// Use conditional describe to skip all tests if credentials are missing
|
||||||
|
(hasS3Credentials ? describe : describe.skip)('Statistics Storage', () => {
|
||||||
|
let storage: any
|
||||||
|
let s3Client: S3Client
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!hasS3Credentials) {
|
||||||
|
console.log('Skipping S3 storage tests: S3_ACCESS_KEY or S3_SECRET_KEY environment variables not set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import S3CompatibleStorage dynamically to avoid issues with dynamic imports
|
||||||
|
const { S3CompatibleStorage } = await import('../dist/storage/adapters/s3CompatibleStorage')
|
||||||
|
|
||||||
|
// Create storage instance
|
||||||
|
storage = new S3CompatibleStorage(storageConfig)
|
||||||
|
await storage.init()
|
||||||
|
|
||||||
|
// Initialize S3 client for checking files
|
||||||
|
s3Client = new S3Client({
|
||||||
|
endpoint: storageConfig.endpoint,
|
||||||
|
region: storageConfig.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: storageConfig.accessKeyId,
|
||||||
|
secretAccessKey: storageConfig.secretAccessKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error initializing S3 storage:', error)
|
||||||
|
throw error // Let the test fail with a clear error message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save statistics data', async () => {
|
||||||
|
await storage.saveStatistics(testStatistics)
|
||||||
|
expect(true).toBe(true) // If no error is thrown, the test passes
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retrieve statistics data after batch update completes', async () => {
|
||||||
|
// Wait for the batch update to complete (longer than MAX_FLUSH_DELAY_MS)
|
||||||
|
await setTimeout(35000)
|
||||||
|
|
||||||
|
const retrievedStats = await storage.getStatistics()
|
||||||
|
expect(retrievedStats).not.toBeNull()
|
||||||
|
|
||||||
|
// Check that all properties match
|
||||||
|
expect(JSON.stringify(retrievedStats.nounCount)).toBe(JSON.stringify(testStatistics.nounCount))
|
||||||
|
expect(JSON.stringify(retrievedStats.verbCount)).toBe(JSON.stringify(testStatistics.verbCount))
|
||||||
|
expect(JSON.stringify(retrievedStats.metadataCount)).toBe(JSON.stringify(testStatistics.metadataCount))
|
||||||
|
expect(retrievedStats.hnswIndexSize).toBe(testStatistics.hnswIndexSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store statistics in time-partitioned files', async () => {
|
||||||
|
// Get current date in YYYYMMDD format
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getUTCFullYear()
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getUTCDate()).padStart(2, '0')
|
||||||
|
const dateStr = `${year}${month}${day}`
|
||||||
|
|
||||||
|
// Check if the file exists in the expected location
|
||||||
|
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
||||||
|
Bucket: storageConfig.bucketName,
|
||||||
|
Prefix: `${storageConfig.prefix}index/statistics_${dateStr}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(listResponse.Contents).toBeDefined()
|
||||||
|
expect(listResponse.Contents?.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain backward compatibility with legacy statistics file', async () => {
|
||||||
|
// Check if the legacy file exists
|
||||||
|
const legacyListResponse = await s3Client.send(new ListObjectsV2Command({
|
||||||
|
Bucket: storageConfig.bucketName,
|
||||||
|
Prefix: `${storageConfig.prefix}index/statistics.json`
|
||||||
|
}))
|
||||||
|
|
||||||
|
// This test is informational - the legacy file may not exist if the 10% random update didn't trigger
|
||||||
|
if (legacyListResponse.Contents && legacyListResponse.Contents.length > 0) {
|
||||||
|
expect(legacyListResponse.Contents.length).toBeGreaterThan(0)
|
||||||
|
} else {
|
||||||
|
console.log('Legacy statistics file not found. This is expected if the 10% random update didn\'t trigger.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
140
tests/statistics.test.ts
Normal file
140
tests/statistics.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Statistics Functionality Tests
|
||||||
|
* Tests the getStatistics function as a consumer would use it
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a 512-dimensional vector for testing
|
||||||
|
* @param primaryIndex The index to set to 1.0, all other indices will be 0.0
|
||||||
|
* @returns A 512-dimensional vector with a single 1.0 value at the specified index
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 512] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Brainy Statistics Functionality', () => {
|
||||||
|
let brainy: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Load brainy library as a consumer would
|
||||||
|
brainy = await import('../dist/unified.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Library Exports', () => {
|
||||||
|
it('should export getStatistics function at the root level', () => {
|
||||||
|
expect(brainy.getStatistics).toBeDefined()
|
||||||
|
expect(typeof brainy.getStatistics).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getStatistics Functionality', () => {
|
||||||
|
it('should retrieve statistics from a BrainyData instance', async () => {
|
||||||
|
// Create a BrainyData instance
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
await data.add(createTestVector(0), { id: 'v1', label: 'x-axis' })
|
||||||
|
await data.add(createTestVector(1), { id: 'v2', label: 'y-axis' })
|
||||||
|
await data.add(createTestVector(2), { id: 'v3', label: 'z-axis' })
|
||||||
|
|
||||||
|
// Add a verb
|
||||||
|
await data.addVerb('v1', 'v2', createTestVector(3), { type: 'connected_to' })
|
||||||
|
|
||||||
|
// Get statistics using the standalone function
|
||||||
|
const stats = await brainy.getStatistics(data)
|
||||||
|
|
||||||
|
// Verify statistics
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats.nounCount).toBe(3)
|
||||||
|
expect(stats.verbCount).toBe(1)
|
||||||
|
expect(stats.metadataCount).toBe(3) // Each noun has metadata
|
||||||
|
expect(stats.hnswIndexSize).toBe(4) // 3 nouns + 1 verb (verbs are also added to HNSW index)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error when no instance is provided', async () => {
|
||||||
|
await expect(brainy.getStatistics()).rejects.toThrow('BrainyData instance must be provided')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should match the instance method results', async () => {
|
||||||
|
// Create a BrainyData instance
|
||||||
|
const data = new brainy.BrainyData({})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
|
||||||
|
// Add some test data
|
||||||
|
await data.add(createTestVector(5), { id: 'test1' })
|
||||||
|
|
||||||
|
// Get statistics using both methods
|
||||||
|
const instanceStats = await data.getStatistics()
|
||||||
|
const functionStats = await brainy.getStatistics(data)
|
||||||
|
|
||||||
|
// Verify core statistics match (ignoring volatile fields like memoryUsage and timestamps)
|
||||||
|
expect(functionStats.nounCount).toBe(instanceStats.nounCount)
|
||||||
|
expect(functionStats.verbCount).toBe(instanceStats.verbCount)
|
||||||
|
expect(functionStats.metadataCount).toBe(instanceStats.metadataCount)
|
||||||
|
expect(functionStats.hnswIndexSize).toBe(instanceStats.hnswIndexSize)
|
||||||
|
|
||||||
|
// If serviceBreakdown exists, verify it matches
|
||||||
|
if (instanceStats.serviceBreakdown) {
|
||||||
|
expect(functionStats.serviceBreakdown).toEqual(instanceStats.serviceBreakdown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track statistics by service', async () => {
|
||||||
|
// Create a BrainyData instance
|
||||||
|
const data = new brainy.BrainyData({
|
||||||
|
metric: 'euclidean'
|
||||||
|
})
|
||||||
|
|
||||||
|
await data.init()
|
||||||
|
await data.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add data from different services
|
||||||
|
await data.add(createTestVector(10), { id: 'v1', label: 'service1-item' }, { service: 'service1' })
|
||||||
|
await data.add(createTestVector(20), { id: 'v2', label: 'service1-item' }, { service: 'service1' })
|
||||||
|
await data.add(createTestVector(30), { id: 'v3', label: 'service2-item' }, { service: 'service2' })
|
||||||
|
|
||||||
|
// Add verbs from different services
|
||||||
|
await data.addVerb('v1', 'v2', undefined, { type: 'related_to', service: 'service1' })
|
||||||
|
await data.addVerb('v2', 'v3', undefined, { type: 'related_to', service: 'service2' })
|
||||||
|
|
||||||
|
// Get statistics for all services
|
||||||
|
const allStats = await data.getStatistics()
|
||||||
|
|
||||||
|
// Verify total counts
|
||||||
|
expect(allStats.nounCount).toBe(3)
|
||||||
|
expect(allStats.verbCount).toBe(2)
|
||||||
|
expect(allStats.metadataCount).toBe(3)
|
||||||
|
|
||||||
|
// Verify service breakdown exists
|
||||||
|
expect(allStats.serviceBreakdown).toBeDefined()
|
||||||
|
|
||||||
|
// Verify service1 statistics
|
||||||
|
const service1Stats = await data.getStatistics({ service: 'service1' })
|
||||||
|
expect(service1Stats.nounCount).toBe(2)
|
||||||
|
expect(service1Stats.verbCount).toBe(1)
|
||||||
|
expect(service1Stats.metadataCount).toBe(2)
|
||||||
|
|
||||||
|
// Verify service2 statistics
|
||||||
|
const service2Stats = await data.getStatistics({ service: 'service2' })
|
||||||
|
expect(service2Stats.nounCount).toBe(1)
|
||||||
|
expect(service2Stats.verbCount).toBe(1)
|
||||||
|
expect(service2Stats.metadataCount).toBe(1)
|
||||||
|
|
||||||
|
// Verify multiple services filter
|
||||||
|
const combinedStats = await data.getStatistics({ service: ['service1', 'service2'] })
|
||||||
|
expect(combinedStats.nounCount).toBe(3)
|
||||||
|
expect(combinedStats.verbCount).toBe(2)
|
||||||
|
expect(combinedStats.metadataCount).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
210
tests/storage-adapter-coverage.test.ts
Normal file
210
tests/storage-adapter-coverage.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* Storage Adapter Coverage Tests
|
||||||
|
*
|
||||||
|
* Purpose:
|
||||||
|
* This test suite verifies that core functionality works correctly across all storage adapters:
|
||||||
|
* 1. Memory Storage
|
||||||
|
* 2. File System Storage
|
||||||
|
* 3. OPFS Storage (when in browser environment)
|
||||||
|
* 4. S3-Compatible Storage (with mocked S3 client)
|
||||||
|
*
|
||||||
|
* These tests ensure consistent behavior regardless of the underlying storage mechanism.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { BrainyData, createStorage } from '../dist/unified.js'
|
||||||
|
import { environment } from '../dist/unified.js'
|
||||||
|
|
||||||
|
// Helper function to run the same tests against different storage adapters
|
||||||
|
const runStorageTests = (
|
||||||
|
adapterName: string,
|
||||||
|
createStorageAdapter: () => Promise<any>
|
||||||
|
) => {
|
||||||
|
describe(`${adapterName} Adapter Tests`, () => {
|
||||||
|
let brainyInstance: any
|
||||||
|
let storage: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create the storage adapter
|
||||||
|
storage = await createStorageAdapter()
|
||||||
|
|
||||||
|
// Create a BrainyData instance with the storage adapter
|
||||||
|
brainyInstance = new BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyInstance.init()
|
||||||
|
|
||||||
|
// Clear any existing data
|
||||||
|
await brainyInstance.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up
|
||||||
|
if (brainyInstance) {
|
||||||
|
await brainyInstance.clear()
|
||||||
|
await brainyInstance.shutDown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Core functionality tests
|
||||||
|
it('should add and retrieve items', async () => {
|
||||||
|
const id = await brainyInstance.add('test data', { source: adapterName })
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
expect(item.metadata.source).toBe(adapterName)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search for items', async () => {
|
||||||
|
// Add multiple items
|
||||||
|
const id1 = await brainyInstance.add('apple banana orange', {
|
||||||
|
fruit: true
|
||||||
|
})
|
||||||
|
const id2 = await brainyInstance.add('car truck motorcycle', {
|
||||||
|
vehicle: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search for fruits
|
||||||
|
const fruitResults = await brainyInstance.search('banana', 5)
|
||||||
|
expect(fruitResults.length).toBeGreaterThan(0)
|
||||||
|
// The fruit item should be found in the results, but not necessarily first
|
||||||
|
// due to potential variations in embedding similarity calculations
|
||||||
|
const fruitItemFound = fruitResults.some((r) => r.id === id1)
|
||||||
|
expect(fruitItemFound).toBe(true)
|
||||||
|
|
||||||
|
// Search for vehicles
|
||||||
|
const vehicleResults = await brainyInstance.search('motorcycle', 5)
|
||||||
|
expect(vehicleResults.length).toBeGreaterThan(0)
|
||||||
|
// The vehicle item should be found in the results, but not necessarily first
|
||||||
|
// due to potential variations in embedding similarity calculations
|
||||||
|
const vehicleItemFound = vehicleResults.some((r) => r.id === id2)
|
||||||
|
expect(vehicleItemFound).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete items', async () => {
|
||||||
|
const id = await brainyInstance.add('test data to delete')
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
let item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeDefined()
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
await brainyInstance.delete(id)
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
item = await brainyInstance.get(id)
|
||||||
|
expect(item).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update metadata', async () => {
|
||||||
|
const id = await brainyInstance.add('test data', { initial: 'metadata' })
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
await brainyInstance.updateMetadata(id, {
|
||||||
|
updated: true,
|
||||||
|
initial: 'changed'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
const item = await brainyInstance.get(id)
|
||||||
|
expect(item.metadata.updated).toBe(true)
|
||||||
|
expect(item.metadata.initial).toBe('changed')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Batch operations test removed - covered by edge-cases.test.ts and performance.test.ts
|
||||||
|
// This test required complex mocking of Universal Sentence Encoder
|
||||||
|
|
||||||
|
it('should handle relationships', async () => {
|
||||||
|
const sourceId = await brainyInstance.add('source item')
|
||||||
|
const targetId = await brainyInstance.add('target item')
|
||||||
|
|
||||||
|
// Create relationship
|
||||||
|
await brainyInstance.relate(sourceId, targetId, 'test-relation')
|
||||||
|
|
||||||
|
// Find similar items
|
||||||
|
const similarItems = await brainyInstance.findSimilar(sourceId)
|
||||||
|
expect(similarItems.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// The exact structure of the results depends on the implementation
|
||||||
|
// but we should at least find the target item
|
||||||
|
const foundTarget = similarItems.some((item) => item.id === targetId)
|
||||||
|
expect(foundTarget).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enforce read-only mode', async () => {
|
||||||
|
// Set to read-only mode
|
||||||
|
brainyInstance.setReadOnly(true)
|
||||||
|
|
||||||
|
// Attempt to add data
|
||||||
|
await expect(brainyInstance.add('test data')).rejects.toThrow(
|
||||||
|
/read-only/i
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify read-only status
|
||||||
|
expect(brainyInstance.isReadOnly()).toBe(true)
|
||||||
|
|
||||||
|
// Reset to writable mode
|
||||||
|
brainyInstance.setReadOnly(false)
|
||||||
|
|
||||||
|
// Now it should work
|
||||||
|
const id = await brainyInstance.add('test data')
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get statistics', async () => {
|
||||||
|
// Add some data
|
||||||
|
await brainyInstance.add('stats test 1')
|
||||||
|
await brainyInstance.add('stats test 2')
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
const stats = await brainyInstance.getStatistics()
|
||||||
|
expect(stats).toBeDefined()
|
||||||
|
expect(stats.nouns).toBeDefined()
|
||||||
|
expect(stats.nouns.count).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backup and restore test removed
|
||||||
|
// This test required special handling for different adapter types
|
||||||
|
// and complex mocking of the Universal Sentence Encoder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Storage Adapter Coverage Tests', () => {
|
||||||
|
// Test Memory Storage
|
||||||
|
runStorageTests('Memory', async () => {
|
||||||
|
return await createStorage({ forceMemoryStorage: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test File System Storage (only in Node.js environment)
|
||||||
|
if (environment.isNode) {
|
||||||
|
runStorageTests('FileSystem', async () => {
|
||||||
|
const tempDir = `./test-fs-storage-${Date.now()}`
|
||||||
|
return await createStorage({
|
||||||
|
forceFileSystemStorage: true,
|
||||||
|
storagePath: tempDir
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test OPFS Storage (only in browser environment)
|
||||||
|
// This is skipped by default since it requires a browser environment
|
||||||
|
if (environment.isBrowser) {
|
||||||
|
describe.skip('OPFS Storage Tests', () => {
|
||||||
|
it('would run OPFS tests in browser environment', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test S3-Compatible Storage with mocked S3 client
|
||||||
|
describe.skip('S3-Compatible Storage Tests', () => {
|
||||||
|
it('would test S3 storage operations if properly configured', () => {
|
||||||
|
// This test is skipped because it requires complex mocking of AWS SDK
|
||||||
|
// The main focus of our fix is on the statistics functionality
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
472
tests/storage-adapters.test.ts
Normal file
472
tests/storage-adapters.test.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
/**
|
||||||
|
* Storage Adapters Tests
|
||||||
|
* Tests for different storage adapters and environment detection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { StorageAdapter } from '../src/coreTypes.js'
|
||||||
|
|
||||||
|
describe('Storage Adapters', () => {
|
||||||
|
// Import modules inside tests to avoid issues with dynamic imports
|
||||||
|
let brainy: any
|
||||||
|
let storageFactory: any
|
||||||
|
let createStorage: any
|
||||||
|
let MemoryStorage: any
|
||||||
|
let FileSystemStorage: any
|
||||||
|
let OPFSStorage: any
|
||||||
|
let S3CompatibleStorage: any
|
||||||
|
let R2Storage: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Load brainy library
|
||||||
|
brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
// Import storage factory
|
||||||
|
storageFactory = await import('../src/storage/storageFactory.js')
|
||||||
|
createStorage = storageFactory.createStorage
|
||||||
|
MemoryStorage = storageFactory.MemoryStorage
|
||||||
|
OPFSStorage = storageFactory.OPFSStorage
|
||||||
|
S3CompatibleStorage = storageFactory.S3CompatibleStorage
|
||||||
|
R2Storage = storageFactory.R2Storage
|
||||||
|
|
||||||
|
// FileSystemStorage needs to be imported separately to avoid browser build issues
|
||||||
|
const fsStorageModule = await import('../src/storage/adapters/fileSystemStorage.js')
|
||||||
|
FileSystemStorage = fsStorageModule.FileSystemStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MemoryStorage', () => {
|
||||||
|
it('should create and initialize MemoryStorage', async () => {
|
||||||
|
const storage = new MemoryStorage()
|
||||||
|
await storage.init()
|
||||||
|
|
||||||
|
expect(storage).toBeDefined()
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.saveMetadata('test-key', { test: 'data' })
|
||||||
|
const metadata = await storage.getMetadata('test-key')
|
||||||
|
|
||||||
|
expect(metadata).toBeDefined()
|
||||||
|
expect(metadata.test).toBe('data')
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await storage.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FileSystemStorage in Node.js', () => {
|
||||||
|
let tempDir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tempDir = `./test-fs-storage-${Date.now()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up the temporary directory
|
||||||
|
if (brainy.environment.isNode) {
|
||||||
|
const fs = await import('fs')
|
||||||
|
const path = await import('path')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Recursive delete of directory
|
||||||
|
const deleteFolderRecursive = async (folderPath: string) => {
|
||||||
|
if (fs.existsSync(folderPath)) {
|
||||||
|
const files = fs.readdirSync(folderPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const curPath = path.join(folderPath, file)
|
||||||
|
if (fs.lstatSync(curPath).isDirectory()) {
|
||||||
|
// Recursive call for directories
|
||||||
|
await deleteFolderRecursive(curPath)
|
||||||
|
} else {
|
||||||
|
// Delete file
|
||||||
|
fs.unlinkSync(curPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmdirSync(folderPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteFolderRecursive(tempDir)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error cleaning up test directory: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create and initialize FileSystemStorage in Node.js environment', async () => {
|
||||||
|
// Skip test if not in Node.js environment
|
||||||
|
if (!brainy.environment.isNode) {
|
||||||
|
console.log('Skipping FileSystemStorage test in non-Node.js environment')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new FileSystemStorage(tempDir)
|
||||||
|
await storage.init()
|
||||||
|
|
||||||
|
expect(storage).toBeDefined()
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.saveMetadata('test-key', { test: 'data' })
|
||||||
|
const metadata = await storage.getMetadata('test-key')
|
||||||
|
|
||||||
|
expect(metadata).toBeDefined()
|
||||||
|
expect(metadata.test).toBe('data')
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await storage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle file system operations correctly', async () => {
|
||||||
|
// Skip test if not in Node.js environment
|
||||||
|
if (!brainy.environment.isNode) {
|
||||||
|
console.log('Skipping FileSystemStorage test in non-Node.js environment')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new FileSystemStorage(tempDir)
|
||||||
|
await storage.init()
|
||||||
|
|
||||||
|
// Test saving and retrieving multiple items
|
||||||
|
const testData = [
|
||||||
|
{ key: 'item1', data: { name: 'Item 1', value: 100 } },
|
||||||
|
{ key: 'item2', data: { name: 'Item 2', value: 200 } },
|
||||||
|
{ key: 'item3', data: { name: 'Item 3', value: 300 } }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
await storage.saveMetadata(item.key, item.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of testData) {
|
||||||
|
const retrievedData = await storage.getMetadata(item.key)
|
||||||
|
expect(retrievedData).toEqual(item.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test storage status
|
||||||
|
const status = await storage.getStorageStatus()
|
||||||
|
expect(status.type).toBe('filesystem')
|
||||||
|
expect(status.used).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await storage.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OPFSStorage in Browser', () => {
|
||||||
|
// Mock OPFS API for testing in Node.js environment
|
||||||
|
let originalWindow: any
|
||||||
|
let mockFileSystemDirectoryHandle: any
|
||||||
|
let mockFileHandle: any
|
||||||
|
let mockWritable: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Save original window object if it exists
|
||||||
|
if (typeof global.window !== 'undefined') {
|
||||||
|
originalWindow = global.window
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock writable
|
||||||
|
mockWritable = {
|
||||||
|
write: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock file handle
|
||||||
|
mockFileHandle = {
|
||||||
|
kind: 'file',
|
||||||
|
getFile: vi.fn().mockResolvedValue({
|
||||||
|
text: vi.fn().mockResolvedValue('{"test":"data"}')
|
||||||
|
}),
|
||||||
|
createWritable: vi.fn().mockResolvedValue(mockWritable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock directory handle
|
||||||
|
mockFileSystemDirectoryHandle = {
|
||||||
|
kind: 'directory',
|
||||||
|
getDirectoryHandle: vi.fn().mockResolvedValue({
|
||||||
|
kind: 'directory',
|
||||||
|
getDirectoryHandle: vi.fn().mockResolvedValue(mockFileSystemDirectoryHandle),
|
||||||
|
getFileHandle: vi.fn().mockResolvedValue(mockFileHandle),
|
||||||
|
removeEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
entries: vi.fn().mockImplementation(function* () {
|
||||||
|
yield ['test-key', mockFileHandle]
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
getFileHandle: vi.fn().mockResolvedValue(mockFileHandle),
|
||||||
|
removeEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
entries: vi.fn().mockImplementation(function* () {
|
||||||
|
yield ['test-key', mockFileHandle]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define navigator.storage if it doesn't exist
|
||||||
|
if (typeof global.navigator === 'undefined') {
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.navigator = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define storage if it doesn't exist
|
||||||
|
if (typeof global.navigator.storage === 'undefined') {
|
||||||
|
global.navigator.storage = {} as any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock storage methods
|
||||||
|
global.navigator.storage.getDirectory = vi.fn().mockResolvedValue(mockFileSystemDirectoryHandle)
|
||||||
|
global.navigator.storage.persisted = vi.fn().mockResolvedValue(true)
|
||||||
|
global.navigator.storage.persist = vi.fn().mockResolvedValue(true)
|
||||||
|
global.navigator.storage.estimate = vi.fn().mockResolvedValue({ usage: 1000, quota: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original window object if it existed
|
||||||
|
if (originalWindow) {
|
||||||
|
global.window = originalWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up mocks
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect OPFS availability correctly', async () => {
|
||||||
|
// Create a new instance with our mocked environment
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
|
||||||
|
// With our mocks in place, OPFS should be available
|
||||||
|
expect(opfsStorage.isOPFSAvailable()).toBe(true)
|
||||||
|
|
||||||
|
// Now remove the getDirectory method to simulate OPFS not being available
|
||||||
|
delete global.navigator.storage.getDirectory
|
||||||
|
|
||||||
|
// Create a new instance with the modified environment
|
||||||
|
const opfsStorage2 = new OPFSStorage()
|
||||||
|
expect(opfsStorage2.isOPFSAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize and perform basic operations with OPFS storage', async () => {
|
||||||
|
// Skip this test and mark it as passed
|
||||||
|
// This is a workaround because properly mocking the OPFS API is complex
|
||||||
|
// and would require more extensive changes to the test environment
|
||||||
|
console.log('Skipping OPFS operations test - would require complex mocking')
|
||||||
|
return
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Environment Detection', () => {
|
||||||
|
// We'll use vi.spyOn to mock environment properties
|
||||||
|
let isNodeSpy: any
|
||||||
|
let isBrowserSpy: any
|
||||||
|
let opfsAvailableSpy: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore all mocks after each test
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select MemoryStorage when forceMemoryStorage is true', async () => {
|
||||||
|
const storage = await createStorage({ forceMemoryStorage: true })
|
||||||
|
expect(storage).toBeInstanceOf(MemoryStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select FileSystemStorage when forceFileSystemStorage is true', async () => {
|
||||||
|
const storage = await createStorage({ forceFileSystemStorage: true })
|
||||||
|
expect(storage).toBeInstanceOf(FileSystemStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select MemoryStorage when type is memory', async () => {
|
||||||
|
const storage = await createStorage({ type: 'memory' })
|
||||||
|
expect(storage).toBeInstanceOf(MemoryStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select FileSystemStorage when type is filesystem', async () => {
|
||||||
|
const storage = await createStorage({ type: 'filesystem' })
|
||||||
|
expect(storage).toBeInstanceOf(FileSystemStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test auto-detection separately
|
||||||
|
describe('Auto-detection', () => {
|
||||||
|
// Create a mock implementation of createStorage that we can control
|
||||||
|
let mockCreateStorage: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a simplified version of createStorage for testing
|
||||||
|
mockCreateStorage = async (options: any = {}) => {
|
||||||
|
// Default to auto type
|
||||||
|
const type = options.type || 'auto'
|
||||||
|
|
||||||
|
// Handle forced storage types
|
||||||
|
if (options.forceMemoryStorage) {
|
||||||
|
return new MemoryStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.forceFileSystemStorage) {
|
||||||
|
return new FileSystemStorage('./test-dir')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle specific storage types
|
||||||
|
if (type !== 'auto') {
|
||||||
|
switch (type) {
|
||||||
|
case 'memory':
|
||||||
|
return new MemoryStorage()
|
||||||
|
case 'filesystem':
|
||||||
|
return new FileSystemStorage('./test-dir')
|
||||||
|
case 'opfs':
|
||||||
|
// Check if OPFS is available
|
||||||
|
const opfs = new OPFSStorage()
|
||||||
|
if (opfs.isOPFSAvailable()) {
|
||||||
|
return opfs
|
||||||
|
}
|
||||||
|
return new MemoryStorage() // Fallback
|
||||||
|
default:
|
||||||
|
return new MemoryStorage() // Default fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detection logic
|
||||||
|
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node
|
||||||
|
const isBrowser = typeof window !== 'undefined'
|
||||||
|
|
||||||
|
// First try OPFS in browser
|
||||||
|
if (isBrowser) {
|
||||||
|
const opfs = new OPFSStorage()
|
||||||
|
if (opfs.isOPFSAvailable()) {
|
||||||
|
return opfs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next try FileSystem in Node.js
|
||||||
|
if (isNode) {
|
||||||
|
return new FileSystemStorage('./test-dir')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to memory storage
|
||||||
|
return new MemoryStorage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select FileSystemStorage in Node.js environment', async () => {
|
||||||
|
// Mock Node.js environment
|
||||||
|
global.process = { versions: { node: '16.0.0' } } as any
|
||||||
|
|
||||||
|
// Mock window as undefined
|
||||||
|
const originalWindow = global.window
|
||||||
|
// @ts-expect-error - Intentionally setting window to undefined
|
||||||
|
global.window = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = await mockCreateStorage({ type: 'auto' })
|
||||||
|
expect(storage).toBeInstanceOf(FileSystemStorage)
|
||||||
|
} finally {
|
||||||
|
// Restore window
|
||||||
|
global.window = originalWindow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select OPFS in browser environment if available', async () => {
|
||||||
|
// Mock browser environment
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.window = {}
|
||||||
|
|
||||||
|
// Mock OPFS availability
|
||||||
|
const opfsStorage = new OPFSStorage()
|
||||||
|
const originalIsOPFSAvailable = opfsStorage.isOPFSAvailable
|
||||||
|
OPFSStorage.prototype.isOPFSAvailable = vi.fn().mockReturnValue(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = await mockCreateStorage({ type: 'auto' })
|
||||||
|
expect(storage).toBeInstanceOf(OPFSStorage)
|
||||||
|
} finally {
|
||||||
|
// Restore original method
|
||||||
|
OPFSStorage.prototype.isOPFSAvailable = originalIsOPFSAvailable
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to MemoryStorage when OPFS is not available in browser', async () => {
|
||||||
|
// Mock browser environment
|
||||||
|
// @ts-expect-error - Mocking global
|
||||||
|
global.window = {}
|
||||||
|
|
||||||
|
// Mock OPFS unavailability
|
||||||
|
OPFSStorage.prototype.isOPFSAvailable = vi.fn().mockReturnValue(false)
|
||||||
|
|
||||||
|
// Mock Node.js environment as undefined to ensure we don't fall back to FileSystemStorage
|
||||||
|
const originalProcess = global.process
|
||||||
|
// @ts-expect-error - Intentionally setting process to undefined
|
||||||
|
global.process = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = await mockCreateStorage({ type: 'auto' })
|
||||||
|
expect(storage).toBeInstanceOf(MemoryStorage)
|
||||||
|
} finally {
|
||||||
|
// Restore process
|
||||||
|
global.process = originalProcess
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('S3CompatibleStorage', () => {
|
||||||
|
// Skip these tests by default as they require actual S3 credentials
|
||||||
|
// These tests are more for documentation purposes
|
||||||
|
it.skip('should create and initialize S3CompatibleStorage', async () => {
|
||||||
|
const storage = new S3CompatibleStorage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
region: 'us-east-1',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key',
|
||||||
|
serviceType: 's3'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock S3 client to avoid actual API calls
|
||||||
|
const mockS3Client = {
|
||||||
|
send: vi.fn().mockResolvedValue({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error - Set mock client
|
||||||
|
storage.s3Client = mockS3Client
|
||||||
|
|
||||||
|
// Mark as initialized to skip actual initialization
|
||||||
|
// @ts-expect-error - Set initialized flag
|
||||||
|
storage.isInitialized = true
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.saveMetadata('test-key', { test: 'data' })
|
||||||
|
|
||||||
|
// Verify S3 client was called
|
||||||
|
expect(mockS3Client.send).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip('should create and initialize R2Storage', async () => {
|
||||||
|
const storage = new R2Storage({
|
||||||
|
bucketName: 'test-bucket',
|
||||||
|
accountId: 'test-account',
|
||||||
|
accessKeyId: 'test-access-key',
|
||||||
|
secretAccessKey: 'test-secret-key'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock S3 client to avoid actual API calls
|
||||||
|
const mockS3Client = {
|
||||||
|
send: vi.fn().mockResolvedValue({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error - Set mock client
|
||||||
|
storage.s3Client = mockS3Client
|
||||||
|
|
||||||
|
// Mark as initialized to skip actual initialization
|
||||||
|
// @ts-expect-error - Set initialized flag
|
||||||
|
storage.isInitialized = true
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.saveMetadata('test-key', { test: 'data' })
|
||||||
|
|
||||||
|
// Verify S3 client was called
|
||||||
|
expect(mockS3Client.send).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
127
tests/test-matrix.md
Normal file
127
tests/test-matrix.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Brainy Test Matrix
|
||||||
|
|
||||||
|
This document outlines a comprehensive testing strategy for the Brainy vector database, ensuring all functionality works correctly across different environments and configurations.
|
||||||
|
|
||||||
|
## Test Dimensions
|
||||||
|
|
||||||
|
The test matrix covers the following dimensions:
|
||||||
|
|
||||||
|
1. **Public Methods**: All public methods of the BrainyData class
|
||||||
|
2. **Storage Adapters**: All supported storage types
|
||||||
|
3. **Environments**: All supported runtime environments
|
||||||
|
4. **Test Types**: Happy path, error handling, edge cases, performance
|
||||||
|
|
||||||
|
## Storage Adapters
|
||||||
|
|
||||||
|
- Memory Storage
|
||||||
|
- File System Storage
|
||||||
|
- OPFS (Origin Private File System) Storage
|
||||||
|
- S3-Compatible Storage (including R2)
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- Browser
|
||||||
|
- Web Worker
|
||||||
|
- Worker Threads
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
- **Happy Path**: Tests with valid inputs and expected behavior
|
||||||
|
- **Error Handling**: Tests with invalid inputs, error conditions
|
||||||
|
- **Edge Cases**: Tests with boundary values, empty inputs, etc.
|
||||||
|
- **Performance**: Tests measuring execution time with various dataset sizes
|
||||||
|
|
||||||
|
## Core Method Test Matrix
|
||||||
|
|
||||||
|
| Method | Memory | FileSystem | OPFS | S3 | Error Handling | Edge Cases | Performance |
|
||||||
|
|--------|--------|------------|------|----|--------------------|------------|-------------|
|
||||||
|
| init() | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ❌ |
|
||||||
|
| add() | ✅ | ✅ | ⚠️ | ❌ | ⚠️ | ⚠️ | ❌ |
|
||||||
|
| addBatch() | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
|
| search() | ✅ | ✅ | ⚠️ | ❌ | ⚠️ | ⚠️ | ❌ |
|
||||||
|
| searchText() | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
|
| get() | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
|
| delete() | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
|
| updateMetadata() | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| relate() | ⚠️ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| findSimilar() | ⚠️ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| clear() | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| isReadOnly()/setReadOnly() | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| getStatistics() | ⚠️ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| backup()/restore() | ⚠️ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
- ✅ Well tested
|
||||||
|
- ⚠️ Partially tested
|
||||||
|
- ❌ Not tested
|
||||||
|
|
||||||
|
## Environment Test Matrix
|
||||||
|
|
||||||
|
| Environment | Memory | FileSystem | OPFS | S3 |
|
||||||
|
|-------------|--------|------------|------|-----|
|
||||||
|
| Node.js | ✅ | ✅ | N/A | ⚠️ |
|
||||||
|
| Browser | ⚠️ | N/A | ⚠️ | ❌ |
|
||||||
|
| Web Worker | ❌ | N/A | ❌ | ❌ |
|
||||||
|
| Worker Threads | ❌ | ⚠️ | N/A | ❌ |
|
||||||
|
|
||||||
|
## Testing Gaps to Address
|
||||||
|
|
||||||
|
1. **Error handling scenarios** for each method
|
||||||
|
- Invalid inputs
|
||||||
|
- Network failures
|
||||||
|
- Storage failures
|
||||||
|
- Concurrent operation conflicts
|
||||||
|
|
||||||
|
2. **Edge cases**
|
||||||
|
- Empty queries
|
||||||
|
- Invalid IDs
|
||||||
|
- Maximum size datasets
|
||||||
|
- Zero-length vectors
|
||||||
|
- Dimension mismatches
|
||||||
|
|
||||||
|
3. **Different storage adapters**
|
||||||
|
- Complete OPFS testing
|
||||||
|
- Complete S3 testing
|
||||||
|
- Test adapter switching/fallback
|
||||||
|
|
||||||
|
4. **Multi-environment behavior**
|
||||||
|
- Browser-specific tests
|
||||||
|
- Web Worker tests
|
||||||
|
- Worker Threads tests
|
||||||
|
|
||||||
|
5. **Read-only mode enforcement**
|
||||||
|
- Test all write operations in read-only mode
|
||||||
|
|
||||||
|
6. **Relationship operations**
|
||||||
|
- Complete testing for relate()
|
||||||
|
- Complete testing for findSimilar()
|
||||||
|
|
||||||
|
7. **Metadata handling**
|
||||||
|
- Test metadata in add/relate operations
|
||||||
|
- Test updateMetadata edge cases
|
||||||
|
|
||||||
|
8. **Large dataset operations**
|
||||||
|
- Performance with 10k+ vectors
|
||||||
|
- Memory usage optimization
|
||||||
|
|
||||||
|
9. **Concurrent operations**
|
||||||
|
- Thread safety
|
||||||
|
- Race condition handling
|
||||||
|
|
||||||
|
10. **Statistics and monitoring**
|
||||||
|
- Accuracy of statistics
|
||||||
|
- Performance impact of statistics tracking
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1. Create error handling tests for core methods
|
||||||
|
2. Create edge case tests for core methods
|
||||||
|
3. Complete storage adapter tests for OPFS and S3
|
||||||
|
4. Create environment-specific test suites
|
||||||
|
5. Implement read-only mode tests
|
||||||
|
6. Complete relationship operation tests
|
||||||
|
7. Create metadata handling tests
|
||||||
|
8. Implement performance tests with various dataset sizes
|
||||||
|
9. Create concurrent operation tests
|
||||||
|
10. Complete statistics and monitoring tests
|
||||||
58
tests/test-setup.ts
Normal file
58
tests/test-setup.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* Global test setup - runs before all tests
|
||||||
|
* Configures mock embeddings to prevent model loading timeouts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock the embedding module globally
|
||||||
|
vi.mock('../src/utils/embedding.js', () => {
|
||||||
|
// Create a deterministic mock embedding function
|
||||||
|
const createMockEmbedding = (dimensions: number = 384) => {
|
||||||
|
return async (input: string | any): Promise<number[]> => {
|
||||||
|
const vector = new Array(dimensions).fill(0)
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
// Use string hash to generate deterministic values
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + input.charCodeAt(i)
|
||||||
|
hash = hash & hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill vector with deterministic values
|
||||||
|
for (let i = 0; i < dimensions; i++) {
|
||||||
|
vector[i] = Math.sin(hash * (i + 1)) * 0.5 + 0.5
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(input) && input.every(x => typeof x === 'number')) {
|
||||||
|
// Already a vector, just return it (padded/truncated to dimensions)
|
||||||
|
return input.slice(0, dimensions).concat(new Array(Math.max(0, dimensions - input.length)).fill(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultEmbeddingFunction: createMockEmbedding(),
|
||||||
|
createEmbeddingFunction: () => createMockEmbedding(),
|
||||||
|
TransformerEmbedding: class {
|
||||||
|
async init() { return this }
|
||||||
|
embed = createMockEmbedding()
|
||||||
|
},
|
||||||
|
UniversalSentenceEncoder: class {
|
||||||
|
async init() { return this }
|
||||||
|
embed = createMockEmbedding()
|
||||||
|
},
|
||||||
|
batchEmbed: async (embedFn: any, inputs: string[]) => {
|
||||||
|
const mockEmbed = createMockEmbedding()
|
||||||
|
return Promise.all(inputs.map(input => mockEmbed(input)))
|
||||||
|
},
|
||||||
|
embeddingFunctions: new Map()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set test environment flag
|
||||||
|
globalThis.__BRAINY_TEST_ENV__ = true
|
||||||
|
|
||||||
|
console.log('✅ Test setup complete - using mock embeddings')
|
||||||
69
tests/test-utils.ts
Normal file
69
tests/test-utils.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* Shared test utilities for all Brainy tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Vector } from '../src/coreTypes.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock embedding function for tests
|
||||||
|
* Returns a deterministic vector based on input string
|
||||||
|
*/
|
||||||
|
export function createMockEmbeddingFunction(dimensions: number = 384) {
|
||||||
|
return async (input: string | any): Promise<Vector> => {
|
||||||
|
// Create a deterministic vector based on input
|
||||||
|
const vector = new Array(dimensions).fill(0)
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
// Use string hash to generate deterministic values
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + input.charCodeAt(i)
|
||||||
|
hash = hash & hash // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill vector with deterministic values
|
||||||
|
for (let i = 0; i < dimensions; i++) {
|
||||||
|
vector[i] = Math.sin(hash * (i + 1)) * 0.5 + 0.5
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(input) && input.every(x => typeof x === 'number')) {
|
||||||
|
// Already a vector, just return it (padded/truncated to dimensions)
|
||||||
|
return input.slice(0, dimensions).concat(new Array(Math.max(0, dimensions - input.length)).fill(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test BrainyData configuration with mocked embedding
|
||||||
|
*/
|
||||||
|
export function createTestConfig(additionalConfig: any = {}) {
|
||||||
|
return {
|
||||||
|
embeddingFunction: createMockEmbeddingFunction(),
|
||||||
|
...additionalConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for async operations to complete
|
||||||
|
*/
|
||||||
|
export async function waitForAsync(ms: number = 10): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock S3 response body helper
|
||||||
|
*/
|
||||||
|
export function createMockS3Body(data: any): any {
|
||||||
|
const jsonString = JSON.stringify(data)
|
||||||
|
return {
|
||||||
|
transformToString: async () => jsonString,
|
||||||
|
transformToByteArray: async () => new TextEncoder().encode(jsonString),
|
||||||
|
transformToWebStream: () => new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(jsonString))
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
306
tests/throttling-metrics.test.ts
Normal file
306
tests/throttling-metrics.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
/**
|
||||||
|
* Tests for throttling metrics collection and reporting
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import { BaseStorageAdapter } from '../src/storage/adapters/baseStorageAdapter.js'
|
||||||
|
import { StatisticsCollector } from '../src/utils/statisticsCollector.js'
|
||||||
|
|
||||||
|
// Mock storage adapter for testing
|
||||||
|
class MockStorageAdapter extends BaseStorageAdapter {
|
||||||
|
private data = new Map<string, any>()
|
||||||
|
private statistics: any = null
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNoun(noun: any): Promise<void> {
|
||||||
|
this.data.set(`noun_${noun.id}`, noun)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNoun(id: string): Promise<any | null> {
|
||||||
|
return this.data.get(`noun_${id}`) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNounsByNounType(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNoun(): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveVerb(verb: any): Promise<void> {
|
||||||
|
this.data.set(`verb_${verb.id}`, verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerb(id: string): Promise<any | null> {
|
||||||
|
return this.data.get(`verb_${id}`) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerbsBySource(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerbsByTarget(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerbsByType(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVerb(): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMetadata(id: string, metadata: any): Promise<void> {
|
||||||
|
this.data.set(`metadata_${id}`, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetadata(id: string): Promise<any | null> {
|
||||||
|
return this.data.get(`metadata_${id}`) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveVerbMetadata(id: string, metadata: any): Promise<void> {
|
||||||
|
this.data.set(`verb_metadata_${id}`, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerbMetadata(id: string): Promise<any | null> {
|
||||||
|
return this.data.get(`verb_metadata_${id}`) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.data.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStorageStatus(): Promise<any> {
|
||||||
|
return { type: 'mock', used: 0, quota: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllNouns(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllVerbs(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNouns(): Promise<any> {
|
||||||
|
return { items: [], hasMore: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerbs(): Promise<any> {
|
||||||
|
return { items: [], hasMore: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async saveStatisticsData(statistics: any): Promise<void> {
|
||||||
|
this.statistics = statistics
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getStatisticsData(): Promise<any | null> {
|
||||||
|
return this.statistics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to simulate throttling error
|
||||||
|
simulateThrottlingError(service?: string): void {
|
||||||
|
const error: any = new Error('Too Many Requests')
|
||||||
|
error.statusCode = 429
|
||||||
|
this.handleThrottling(error, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to simulate successful operation after throttling
|
||||||
|
simulateSuccessAfterThrottling(): void {
|
||||||
|
this.clearThrottlingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose throttling metrics for testing
|
||||||
|
getThrottlingMetricsForTesting() {
|
||||||
|
return this.getThrottlingMetrics()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Throttling Metrics', () => {
|
||||||
|
let storage: MockStorageAdapter
|
||||||
|
let collector: StatisticsCollector
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MockStorageAdapter()
|
||||||
|
collector = new StatisticsCollector()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BaseStorageAdapter throttling detection', () => {
|
||||||
|
it('should detect 429 errors as throttling', () => {
|
||||||
|
const error: any = new Error('Too Many Requests')
|
||||||
|
error.statusCode = 429
|
||||||
|
expect((storage as any).isThrottlingError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect 503 errors as throttling', () => {
|
||||||
|
const error: any = new Error('Service Unavailable')
|
||||||
|
error.statusCode = 503
|
||||||
|
expect((storage as any).isThrottlingError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect rate limit messages as throttling', () => {
|
||||||
|
const error = new Error('Rate limit exceeded')
|
||||||
|
expect((storage as any).isThrottlingError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should detect quota exceeded as throttling', () => {
|
||||||
|
const error = new Error('Quota exceeded for this resource')
|
||||||
|
expect((storage as any).isThrottlingError(error)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not detect regular errors as throttling', () => {
|
||||||
|
const error = new Error('File not found')
|
||||||
|
expect((storage as any).isThrottlingError(error)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Throttling event tracking', () => {
|
||||||
|
it('should track throttling events', async () => {
|
||||||
|
storage.simulateThrottlingError('test-service')
|
||||||
|
|
||||||
|
const metrics = storage.getThrottlingMetricsForTesting()
|
||||||
|
expect(metrics?.storage?.currentlyThrottled).toBe(true)
|
||||||
|
expect(metrics?.storage?.totalThrottleEvents).toBe(1)
|
||||||
|
expect(metrics?.storage?.consecutiveThrottleEvents).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track service-level throttling', async () => {
|
||||||
|
storage.simulateThrottlingError('service-1')
|
||||||
|
storage.simulateThrottlingError('service-2')
|
||||||
|
|
||||||
|
const metrics = storage.getThrottlingMetricsForTesting()
|
||||||
|
expect(metrics?.serviceThrottling?.['service-1']?.throttleCount).toBe(1)
|
||||||
|
expect(metrics?.serviceThrottling?.['service-2']?.throttleCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should implement exponential backoff', async () => {
|
||||||
|
storage.simulateThrottlingError()
|
||||||
|
const metrics1 = storage.getThrottlingMetricsForTesting()
|
||||||
|
const backoff1 = metrics1?.storage?.currentBackoffMs || 0
|
||||||
|
|
||||||
|
storage.simulateThrottlingError()
|
||||||
|
const metrics2 = storage.getThrottlingMetricsForTesting()
|
||||||
|
const backoff2 = metrics2?.storage?.currentBackoffMs || 0
|
||||||
|
|
||||||
|
expect(backoff2).toBeGreaterThan(backoff1)
|
||||||
|
expect(backoff2).toBe(Math.min(backoff1 * 2, 30000))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear throttling state after success', async () => {
|
||||||
|
storage.simulateThrottlingError()
|
||||||
|
|
||||||
|
let metrics = storage.getThrottlingMetricsForTesting()
|
||||||
|
expect(metrics?.storage?.currentlyThrottled).toBe(true)
|
||||||
|
|
||||||
|
storage.simulateSuccessAfterThrottling()
|
||||||
|
|
||||||
|
metrics = storage.getThrottlingMetricsForTesting()
|
||||||
|
expect(metrics?.storage?.currentlyThrottled).toBe(false)
|
||||||
|
expect(metrics?.storage?.consecutiveThrottleEvents).toBe(0)
|
||||||
|
expect(metrics?.storage?.currentBackoffMs).toBe(1000) // Reset to initial
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track throttle reasons', async () => {
|
||||||
|
const error429: any = new Error('Too Many Requests')
|
||||||
|
error429.statusCode = 429
|
||||||
|
await storage.handleThrottling(error429)
|
||||||
|
|
||||||
|
const error503: any = new Error('Service Unavailable')
|
||||||
|
error503.statusCode = 503
|
||||||
|
await storage.handleThrottling(error503)
|
||||||
|
|
||||||
|
const metrics = storage.getThrottlingMetricsForTesting()
|
||||||
|
expect(metrics?.storage?.throttleReasons?.['429_TooManyRequests']).toBe(1)
|
||||||
|
expect(metrics?.storage?.throttleReasons?.['503_ServiceUnavailable']).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('StatisticsCollector throttling metrics', () => {
|
||||||
|
it('should track throttling events in collector', () => {
|
||||||
|
collector.trackThrottlingEvent('429_TooManyRequests', 'test-service')
|
||||||
|
|
||||||
|
const stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.storage?.currentlyThrottled).toBe(true)
|
||||||
|
expect(stats.throttlingMetrics?.storage?.totalThrottleEvents).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track delayed operations', () => {
|
||||||
|
collector.trackDelayedOperation(1000)
|
||||||
|
collector.trackDelayedOperation(2000)
|
||||||
|
|
||||||
|
const stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.operationImpact?.delayedOperations).toBe(2)
|
||||||
|
expect(stats.throttlingMetrics?.operationImpact?.totalDelayMs).toBe(3000)
|
||||||
|
expect(stats.throttlingMetrics?.operationImpact?.averageDelayMs).toBe(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track retried operations', () => {
|
||||||
|
collector.trackRetriedOperation()
|
||||||
|
collector.trackRetriedOperation()
|
||||||
|
|
||||||
|
const stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.operationImpact?.retriedOperations).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track failed operations due to throttling', () => {
|
||||||
|
collector.trackFailedDueToThrottling()
|
||||||
|
|
||||||
|
const stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.operationImpact?.failedDueToThrottling).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear throttling state', () => {
|
||||||
|
collector.trackThrottlingEvent('429_TooManyRequests')
|
||||||
|
let stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.storage?.currentlyThrottled).toBe(true)
|
||||||
|
|
||||||
|
collector.clearThrottlingState()
|
||||||
|
stats = collector.getStatistics()
|
||||||
|
expect(stats.throttlingMetrics?.storage?.currentlyThrottled).toBe(false)
|
||||||
|
expect(stats.throttlingMetrics?.storage?.consecutiveThrottleEvents).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Integration with BrainyData', () => {
|
||||||
|
it('should include throttling metrics structure in getStatistics', async () => {
|
||||||
|
const db = new BrainyData({
|
||||||
|
storage: { type: 'memory' },
|
||||||
|
embedding: { type: 'use' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
await db.addBatch([
|
||||||
|
{ key: 'test1', data: { content: 'test' } }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Get statistics with forceRefresh to ensure collector stats are included
|
||||||
|
const stats = await db.getStatistics({ forceRefresh: true })
|
||||||
|
|
||||||
|
// The throttling metrics should be included in the stats from the collector
|
||||||
|
// Even if there are no throttling events, the structure should exist
|
||||||
|
// Check that either throttlingMetrics exists or the stats object has the expected base structure
|
||||||
|
if ((stats as any).throttlingMetrics) {
|
||||||
|
expect((stats as any).throttlingMetrics).toHaveProperty('storage')
|
||||||
|
expect((stats as any).throttlingMetrics).toHaveProperty('operationImpact')
|
||||||
|
|
||||||
|
// Check that the metrics have the expected structure
|
||||||
|
const throttling = (stats as any).throttlingMetrics
|
||||||
|
expect(throttling.storage).toHaveProperty('currentlyThrottled')
|
||||||
|
expect(throttling.storage).toHaveProperty('totalThrottleEvents')
|
||||||
|
expect(throttling.operationImpact).toHaveProperty('delayedOperations')
|
||||||
|
} else {
|
||||||
|
// If throttling metrics don't exist yet, at least verify the basic stats structure
|
||||||
|
expect(stats).toHaveProperty('nounCount')
|
||||||
|
expect(stats).toHaveProperty('verbCount')
|
||||||
|
expect(stats).toHaveProperty('metadataCount')
|
||||||
|
console.log('Note: Throttling metrics not yet included in stats (this is expected initially)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
94
tests/type-utils.test.ts
Normal file
94
tests/type-utils.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Tests for type utility functions
|
||||||
|
*
|
||||||
|
* This test file verifies that the utility functions for accessing noun and verb types
|
||||||
|
* work correctly and return the expected values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
NounType,
|
||||||
|
VerbType,
|
||||||
|
getNounTypes,
|
||||||
|
getVerbTypes,
|
||||||
|
getNounTypeMap,
|
||||||
|
getVerbTypeMap
|
||||||
|
} from '../src/index.js'
|
||||||
|
|
||||||
|
describe('Type Utility Functions', () => {
|
||||||
|
describe('getNounTypes', () => {
|
||||||
|
it('should return an array of all noun types', () => {
|
||||||
|
const nounTypes = getNounTypes()
|
||||||
|
|
||||||
|
// Check that the result is an array
|
||||||
|
expect(Array.isArray(nounTypes)).toBe(true)
|
||||||
|
|
||||||
|
// Check that it contains all the expected values
|
||||||
|
expect(nounTypes).toContain(NounType.Person)
|
||||||
|
expect(nounTypes).toContain(NounType.Organization)
|
||||||
|
expect(nounTypes).toContain(NounType.Location)
|
||||||
|
expect(nounTypes).toContain(NounType.Thing)
|
||||||
|
expect(nounTypes).toContain(NounType.Concept)
|
||||||
|
|
||||||
|
// Check that the length matches the number of properties in NounType
|
||||||
|
expect(nounTypes.length).toBe(Object.keys(NounType).length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVerbTypes', () => {
|
||||||
|
it('should return an array of all verb types', () => {
|
||||||
|
const verbTypes = getVerbTypes()
|
||||||
|
|
||||||
|
// Check that the result is an array
|
||||||
|
expect(Array.isArray(verbTypes)).toBe(true)
|
||||||
|
|
||||||
|
// Check that it contains some expected values
|
||||||
|
expect(verbTypes).toContain(VerbType.RelatedTo)
|
||||||
|
expect(verbTypes).toContain(VerbType.Contains)
|
||||||
|
expect(verbTypes).toContain(VerbType.PartOf)
|
||||||
|
expect(verbTypes).toContain(VerbType.LocatedAt)
|
||||||
|
expect(verbTypes).toContain(VerbType.References)
|
||||||
|
|
||||||
|
// Check that the length matches the number of properties in VerbType
|
||||||
|
expect(verbTypes.length).toBe(Object.keys(VerbType).length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getNounTypeMap', () => {
|
||||||
|
it('should return a map of all noun type keys to values', () => {
|
||||||
|
const nounTypeMap = getNounTypeMap()
|
||||||
|
|
||||||
|
// Check that the result is an object
|
||||||
|
expect(typeof nounTypeMap).toBe('object')
|
||||||
|
|
||||||
|
// Check that it contains all the expected keys and values
|
||||||
|
expect(nounTypeMap.Person).toBe(NounType.Person)
|
||||||
|
expect(nounTypeMap.Organization).toBe(NounType.Organization)
|
||||||
|
expect(nounTypeMap.Location).toBe(NounType.Location)
|
||||||
|
expect(nounTypeMap.Thing).toBe(NounType.Thing)
|
||||||
|
expect(nounTypeMap.Concept).toBe(NounType.Concept)
|
||||||
|
|
||||||
|
// Check that the number of keys matches the number of properties in NounType
|
||||||
|
expect(Object.keys(nounTypeMap).length).toBe(Object.keys(NounType).length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVerbTypeMap', () => {
|
||||||
|
it('should return a map of all verb type keys to values', () => {
|
||||||
|
const verbTypeMap = getVerbTypeMap()
|
||||||
|
|
||||||
|
// Check that the result is an object
|
||||||
|
expect(typeof verbTypeMap).toBe('object')
|
||||||
|
|
||||||
|
// Check that it contains all the expected keys and values
|
||||||
|
expect(verbTypeMap.RelatedTo).toBe(VerbType.RelatedTo)
|
||||||
|
expect(verbTypeMap.Contains).toBe(VerbType.Contains)
|
||||||
|
expect(verbTypeMap.PartOf).toBe(VerbType.PartOf)
|
||||||
|
expect(verbTypeMap.LocatedAt).toBe(VerbType.LocatedAt)
|
||||||
|
expect(verbTypeMap.References).toBe(VerbType.References)
|
||||||
|
|
||||||
|
// Check that the number of keys matches the number of properties in VerbType
|
||||||
|
expect(Object.keys(verbTypeMap).length).toBe(Object.keys(VerbType).length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
251
tests/unified-api.test.ts
Normal file
251
tests/unified-api.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
/**
|
||||||
|
* Unified API Tests for Brainy 1.0
|
||||||
|
* Tests the 7 core unified methods and new functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData, NounType, VerbType } from '../src/index.js'
|
||||||
|
|
||||||
|
describe('Brainy 1.0 Unified API', () => {
|
||||||
|
let brainy: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brainy = new BrainyData()
|
||||||
|
await brainy.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainy) {
|
||||||
|
await brainy.cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 1: add()', () => {
|
||||||
|
it('should add data with smart processing by default', async () => {
|
||||||
|
const id = await brainy.add("John Doe is a software engineer at Tech Corp")
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with literal processing when specified', async () => {
|
||||||
|
const id = await brainy.add("Raw data", {}, { process: 'literal' })
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with metadata', async () => {
|
||||||
|
const metadata = { type: 'person', age: 30 }
|
||||||
|
const id = await brainy.add("Jane Smith", metadata)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add data with encryption', async () => {
|
||||||
|
const id = await brainy.add("Sensitive data", {}, { encrypt: true })
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 2: search()', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add test data
|
||||||
|
await brainy.add("Alice is a data scientist")
|
||||||
|
await brainy.add("Bob is a software engineer")
|
||||||
|
await brainy.add("Charlie works in marketing")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should perform vector similarity search', async () => {
|
||||||
|
const results = await brainy.search("data scientist", 5)
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search with metadata filters', async () => {
|
||||||
|
await brainy.add("David", { department: "engineering" })
|
||||||
|
await brainy.add("Emma", { department: "marketing" })
|
||||||
|
|
||||||
|
const results = await brainy.search("", 10, {
|
||||||
|
metadata: { department: "engineering" }
|
||||||
|
})
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(Array.isArray(results)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search connected nouns', async () => {
|
||||||
|
const personId = await brainy.addNoun("Frank", NounType.Person)
|
||||||
|
const companyId = await brainy.addNoun("Tech Inc", NounType.Organization)
|
||||||
|
await brainy.addVerb(personId, companyId, VerbType.WorksWith)
|
||||||
|
|
||||||
|
const results = await brainy.search("", 10, {
|
||||||
|
searchConnectedNouns: true,
|
||||||
|
sourceId: personId
|
||||||
|
})
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 3: import()', () => {
|
||||||
|
it('should import array of data items', async () => {
|
||||||
|
const data = [
|
||||||
|
"Item 1",
|
||||||
|
"Item 2",
|
||||||
|
"Item 3"
|
||||||
|
]
|
||||||
|
const ids = await brainy.import(data)
|
||||||
|
expect(ids).toBeDefined()
|
||||||
|
expect(Array.isArray(ids)).toBe(true)
|
||||||
|
expect(ids.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should import with metadata for each item', async () => {
|
||||||
|
const data = [
|
||||||
|
{ data: "Item 1", metadata: { category: "A" } },
|
||||||
|
{ data: "Item 2", metadata: { category: "B" } }
|
||||||
|
]
|
||||||
|
const ids = await brainy.import(data)
|
||||||
|
expect(ids.length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 4: addNoun()', () => {
|
||||||
|
it('should add typed noun entities', async () => {
|
||||||
|
const personId = await brainy.addNoun("John Doe", NounType.Person)
|
||||||
|
expect(personId).toBeDefined()
|
||||||
|
|
||||||
|
const orgId = await brainy.addNoun("ACME Corp", NounType.Organization)
|
||||||
|
expect(orgId).toBeDefined()
|
||||||
|
|
||||||
|
const locationId = await brainy.addNoun("San Francisco", NounType.Location)
|
||||||
|
expect(locationId).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add noun with metadata', async () => {
|
||||||
|
const metadata = { age: 25, role: "Engineer" }
|
||||||
|
const id = await brainy.addNoun("Jane Smith", NounType.Person, metadata)
|
||||||
|
expect(id).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 5: addVerb()', () => {
|
||||||
|
it('should create relationships between nouns', async () => {
|
||||||
|
const personId = await brainy.addNoun("Bob Wilson", NounType.Person)
|
||||||
|
const companyId = await brainy.addNoun("Tech Solutions", NounType.Organization)
|
||||||
|
|
||||||
|
const verbId = await brainy.addVerb(personId, companyId, VerbType.WorksWith)
|
||||||
|
expect(verbId).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create verb with metadata and weight', async () => {
|
||||||
|
const sourceId = await brainy.addNoun("Alice", NounType.Person)
|
||||||
|
const targetId = await brainy.addNoun("Project Alpha", NounType.Project)
|
||||||
|
|
||||||
|
const verbId = await brainy.addVerb(
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
VerbType.WorksWith,
|
||||||
|
{ role: "Lead Developer", since: "2024" },
|
||||||
|
0.9
|
||||||
|
)
|
||||||
|
expect(verbId).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 6: update()', () => {
|
||||||
|
it('should update existing data', async () => {
|
||||||
|
const id = await brainy.add("Original data")
|
||||||
|
const success = await brainy.update(id, "Updated data")
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update data and metadata', async () => {
|
||||||
|
const id = await brainy.add("Data", { version: 1 })
|
||||||
|
const success = await brainy.update(id, "Updated data", { version: 2 })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update with cascade option', async () => {
|
||||||
|
const personId = await brainy.addNoun("Charlie", NounType.Person)
|
||||||
|
const success = await brainy.update(
|
||||||
|
personId,
|
||||||
|
"Charles Thompson",
|
||||||
|
{ fullName: "Charles Thompson" },
|
||||||
|
{ cascade: true }
|
||||||
|
)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Core Method 7: delete()', () => {
|
||||||
|
it('should soft delete by default', async () => {
|
||||||
|
const id = await brainy.add("Test data for deletion")
|
||||||
|
const success = await brainy.delete(id)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
|
||||||
|
// Should not appear in search results
|
||||||
|
const results = await brainy.search("Test data for deletion", 10)
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hard delete when specified', async () => {
|
||||||
|
const id = await brainy.add("Data to hard delete")
|
||||||
|
const success = await brainy.delete(id, { soft: false })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cascade delete related verbs', async () => {
|
||||||
|
const personId = await brainy.addNoun("Dave", NounType.Person)
|
||||||
|
const projectId = await brainy.addNoun("Project Beta", NounType.Project)
|
||||||
|
await brainy.addVerb(personId, projectId, VerbType.WorksWith)
|
||||||
|
|
||||||
|
const success = await brainy.delete(personId, { cascade: true })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should force delete even with relationships', async () => {
|
||||||
|
const personId = await brainy.addNoun("Eve", NounType.Person)
|
||||||
|
const taskId = await brainy.addNoun("Important Task", NounType.Task)
|
||||||
|
await brainy.addVerb(personId, taskId, VerbType.Owns)
|
||||||
|
|
||||||
|
const success = await brainy.delete(personId, { force: true })
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Encryption Features', () => {
|
||||||
|
it('should encrypt and decrypt configuration', async () => {
|
||||||
|
await brainy.setConfig('api-key', 'secret-value', { encrypt: true })
|
||||||
|
const value = await brainy.getConfig('api-key', { decrypt: true })
|
||||||
|
expect(value).toBe('secret-value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should encrypt individual data items', async () => {
|
||||||
|
const encrypted = await brainy.encryptData('sensitive information')
|
||||||
|
expect(encrypted).toBeDefined()
|
||||||
|
expect(encrypted).not.toBe('sensitive information')
|
||||||
|
|
||||||
|
const decrypted = await brainy.decryptData(encrypted)
|
||||||
|
expect(decrypted).toBe('sensitive information')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Container & Model Preloading', () => {
|
||||||
|
it('should support model preloading configuration', async () => {
|
||||||
|
// Test preload configuration (doesn't actually download in tests)
|
||||||
|
const config = {
|
||||||
|
model: 'Xenova/all-MiniLM-L6-v2',
|
||||||
|
cacheDir: './test-models'
|
||||||
|
}
|
||||||
|
// Just test that the method exists and doesn't throw
|
||||||
|
expect(() => BrainyData.preloadModel).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support warmup initialization', async () => {
|
||||||
|
const options = {
|
||||||
|
storage: { forceMemoryStorage: true }
|
||||||
|
}
|
||||||
|
const warmupOptions = { preloadModel: true }
|
||||||
|
|
||||||
|
// Test that warmup method exists and configuration is accepted
|
||||||
|
expect(() => BrainyData.warmup).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
156
tests/vector-operations.test.ts
Normal file
156
tests/vector-operations.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { euclideanDistance } from '../src/utils/distance.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a 384-dimensional vector for testing
|
||||||
|
* @param primaryIndex The index to set to 1.0, all other indices will be 0.0
|
||||||
|
* @returns A 384-dimensional vector with a single 1.0 value at the specified index
|
||||||
|
*/
|
||||||
|
function createTestVector(primaryIndex: number = 0): number[] {
|
||||||
|
const vector = new Array(384).fill(0)
|
||||||
|
vector[primaryIndex % 384] = 1.0
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Vector Operations', () => {
|
||||||
|
it('should load brainy library successfully', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
expect(brainy).toBeDefined()
|
||||||
|
expect(typeof brainy.BrainyData).toBe('function')
|
||||||
|
expect(brainy.environment).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create and initialize BrainyData instance', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
distanceFunction: euclideanDistance
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(db).toBeDefined()
|
||||||
|
expect(db.dimensions).toBe(384)
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
// If we get here without throwing, initialization was successful
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle simple vector operations', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
// Explicitly use memory storage to avoid FileSystemStorage issues
|
||||||
|
const storage = await brainy.createStorage({ forceMemoryStorage: true })
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
distanceFunction: euclideanDistance,
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add a simple vector
|
||||||
|
const testVector = createTestVector(1)
|
||||||
|
await db.add(testVector, { id: 'test' })
|
||||||
|
|
||||||
|
// Search for the same vector
|
||||||
|
const results = await db.search(testVector, 1)
|
||||||
|
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThan(0)
|
||||||
|
expect(results[0].metadata.id).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple vector searches correctly', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
// Explicitly use memory storage to avoid FileSystemStorage issues
|
||||||
|
const storage = await brainy.createStorage({ forceMemoryStorage: true })
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
distanceFunction: euclideanDistance,
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
await db.clear() // Clear any existing data
|
||||||
|
|
||||||
|
// Add multiple vectors
|
||||||
|
await db.add(createTestVector(0), { id: 'vec1', type: 'unit' })
|
||||||
|
await db.add(createTestVector(1), { id: 'vec2', type: 'unit' })
|
||||||
|
await db.add(createTestVector(2), { id: 'vec3', type: 'unit' })
|
||||||
|
|
||||||
|
// Create a mixed vector with two non-zero elements
|
||||||
|
const mixedVector = createTestVector(3)
|
||||||
|
mixedVector[4] = 0.5
|
||||||
|
await db.add(mixedVector, { id: 'vec4', type: 'mixed' })
|
||||||
|
|
||||||
|
// Search for multiple results
|
||||||
|
const results = await db.search(createTestVector(0), 3)
|
||||||
|
|
||||||
|
expect(results).toBeDefined()
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(results.length).toBeLessThanOrEqual(3)
|
||||||
|
|
||||||
|
// The closest should be the exact match
|
||||||
|
expect(results[0].metadata.id).toBe('vec1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate similarity between vectors correctly', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
// Explicitly use memory storage to avoid FileSystemStorage issues
|
||||||
|
const storage = await brainy.createStorage({ forceMemoryStorage: true })
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
distanceFunction: euclideanDistance,
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Create test vectors
|
||||||
|
const vectorA = createTestVector(0)
|
||||||
|
const vectorB = createTestVector(0) // Identical to vectorA
|
||||||
|
const vectorC = createTestVector(1) // Different from vectorA
|
||||||
|
|
||||||
|
// Calculate similarity between identical vectors
|
||||||
|
const similarityIdentical = await db.calculateSimilarity(vectorA, vectorB)
|
||||||
|
|
||||||
|
// Calculate similarity between different vectors
|
||||||
|
const similarityDifferent = await db.calculateSimilarity(vectorA, vectorC)
|
||||||
|
|
||||||
|
// Identical vectors should have similarity close to 1
|
||||||
|
expect(similarityIdentical).toBeCloseTo(1, 1)
|
||||||
|
|
||||||
|
// Different vectors should have lower similarity
|
||||||
|
expect(similarityDifferent).toBeLessThan(similarityIdentical)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate similarity between text inputs correctly', async () => {
|
||||||
|
const brainy = await import('../dist/unified.js')
|
||||||
|
|
||||||
|
// Explicitly use memory storage to avoid FileSystemStorage issues
|
||||||
|
const storage = await brainy.createStorage({ forceMemoryStorage: true })
|
||||||
|
const db = new brainy.BrainyData({
|
||||||
|
storageAdapter: storage
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
// Calculate similarity between similar texts
|
||||||
|
const similarityHigh = await db.calculateSimilarity(
|
||||||
|
'Cats are furry pets',
|
||||||
|
'Felines make good companions'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate similarity between different texts
|
||||||
|
const similarityLow = await db.calculateSimilarity(
|
||||||
|
'Cats are furry pets',
|
||||||
|
'Python is a programming language'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Similar texts should have similarity at least as high as different texts
|
||||||
|
// Note: In some cases with small test texts, the similarity values might be equal
|
||||||
|
// This is a more robust test that doesn't fail when both are 1
|
||||||
|
expect(similarityHigh).toBeGreaterThanOrEqual(similarityLow)
|
||||||
|
})
|
||||||
|
})
|
||||||
56
tests/verify-custom-models.js
Normal file
56
tests/verify-custom-models.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify custom models path functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../dist/unified.js'
|
||||||
|
|
||||||
|
console.log('🧪 Testing Custom Models Path Functionality\n')
|
||||||
|
|
||||||
|
// Test 1: Environment variable
|
||||||
|
console.log('Test 1: Environment Variable Support')
|
||||||
|
process.env.BRAINY_MODELS_PATH = '/tmp/test-models'
|
||||||
|
|
||||||
|
const db1 = new BrainyData({
|
||||||
|
forceMemoryStorage: true,
|
||||||
|
dimensions: 512,
|
||||||
|
skipEmbeddings: true // Skip to avoid model loading in test
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(` BRAINY_MODELS_PATH set to: ${process.env.BRAINY_MODELS_PATH}`)
|
||||||
|
console.log(' ✅ Environment variable configuration working')
|
||||||
|
|
||||||
|
// Test 2: Show warning messages
|
||||||
|
console.log('\nTest 2: Warning Messages')
|
||||||
|
const db2 = new BrainyData({
|
||||||
|
forceMemoryStorage: true,
|
||||||
|
dimensions: 512,
|
||||||
|
skipEmbeddings: false // This will trigger model loading and warnings
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(' Initializing with embeddings enabled to show warnings...')
|
||||||
|
try {
|
||||||
|
await db2.init()
|
||||||
|
console.log(' ✅ Model loaded successfully (local models found)')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ Model loading failed (expected - shows warning messages)')
|
||||||
|
console.log(' Check the warning messages above for custom path instructions')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nTest 3: Model Search Path Priority')
|
||||||
|
console.log(' The model loader will search in this order:')
|
||||||
|
console.log(' 1. Custom models path (BRAINY_MODELS_PATH)')
|
||||||
|
console.log(' 2. @soulcraft/brainy-models package')
|
||||||
|
console.log(' 3. Fallback to remote URLs')
|
||||||
|
|
||||||
|
console.log('\n🎯 Key Benefits for Docker Deployments:')
|
||||||
|
console.log(' • Embed models in Docker images outside node_modules')
|
||||||
|
console.log(' • Avoid runtime model downloads')
|
||||||
|
console.log(' • Work in offline/restricted network environments')
|
||||||
|
console.log(' • Faster application startup')
|
||||||
|
console.log(' • Predictable memory usage')
|
||||||
|
|
||||||
|
console.log('\n📚 See examples/docker-deployment/ for complete examples')
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
37
tests/verify-model-loading.js
Normal file
37
tests/verify-model-loading.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple script to verify model loading behavior
|
||||||
|
* Run with: node tests/verify-model-loading.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../dist/unified.js'
|
||||||
|
|
||||||
|
console.log('Testing Brainy model loading behavior...\n')
|
||||||
|
|
||||||
|
const db = new BrainyData({
|
||||||
|
forceMemoryStorage: true,
|
||||||
|
dimensions: 512
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Initializing BrainyData...')
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
console.log('\nAttempting to embed text...')
|
||||||
|
const text = 'This is a test sentence for embedding'
|
||||||
|
const id = await db.add({ content: text })
|
||||||
|
|
||||||
|
console.log(`\n✅ Successfully added text with ID: ${id}`)
|
||||||
|
|
||||||
|
// Test search
|
||||||
|
const results = await db.search('test sentence', 1)
|
||||||
|
console.log(`\n✅ Search returned ${results.length} result(s)`)
|
||||||
|
|
||||||
|
console.log('\n---')
|
||||||
|
console.log('Model loading test complete!')
|
||||||
|
console.log('\nNOTE: Check the console output above to see:')
|
||||||
|
console.log('1. If @soulcraft/brainy-models was found and used (best performance)')
|
||||||
|
console.log('2. If fallback to URL loading occurred (with warning)')
|
||||||
|
console.log('3. Verification that the correct model was loaded')
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
46
tests/verify-model-priority-simple.js
Normal file
46
tests/verify-model-priority-simple.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple test to verify model loading priority messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrainyData } from '../dist/unified.js'
|
||||||
|
|
||||||
|
console.log('Testing model loading priority system...\n')
|
||||||
|
|
||||||
|
const db = new BrainyData({
|
||||||
|
forceMemoryStorage: true,
|
||||||
|
dimensions: 512,
|
||||||
|
skipEmbeddings: true // Skip embeddings to avoid model loading for this test
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Initializing BrainyData with skipEmbeddings=true...')
|
||||||
|
await db.init()
|
||||||
|
|
||||||
|
console.log('\n✅ BrainyData initialized successfully (embeddings skipped)')
|
||||||
|
|
||||||
|
// Now test with embeddings enabled to see the warnings
|
||||||
|
console.log('\n---')
|
||||||
|
console.log('Now testing with embeddings enabled to see model loading messages...\n')
|
||||||
|
|
||||||
|
const db2 = new BrainyData({
|
||||||
|
forceMemoryStorage: true,
|
||||||
|
dimensions: 512,
|
||||||
|
skipEmbeddings: false
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db2.init()
|
||||||
|
console.log('\n✅ Model loaded successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.log('\n❌ Model loading failed (expected in test environment without actual models)')
|
||||||
|
console.log(` Error: ${error.message.split('\n')[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n---')
|
||||||
|
console.log('Check the output above to verify:')
|
||||||
|
console.log('1. "Checking for @soulcraft/brainy-models package..." appears')
|
||||||
|
console.log('2. Warning about falling back to remote loading appears')
|
||||||
|
console.log('3. Installation suggestion for @soulcraft/brainy-models appears')
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
339
tests/write-only-direct-reads.test.ts
Normal file
339
tests/write-only-direct-reads.test.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { BrainyData } from '../src/brainyData.js'
|
||||||
|
import type { BrainyDataConfig } from '../src/brainyData.js'
|
||||||
|
|
||||||
|
describe('Write-Only Mode with Direct Reads', () => {
|
||||||
|
let brainyWriteOnly: BrainyData
|
||||||
|
let brainyWithDirectReads: BrainyData
|
||||||
|
let brainyNormal: BrainyData
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create instances with different configurations
|
||||||
|
brainyWriteOnly = new BrainyData({
|
||||||
|
writeOnly: true,
|
||||||
|
allowDirectReads: false
|
||||||
|
})
|
||||||
|
|
||||||
|
brainyWithDirectReads = new BrainyData({
|
||||||
|
writeOnly: true,
|
||||||
|
allowDirectReads: true
|
||||||
|
})
|
||||||
|
|
||||||
|
brainyNormal = new BrainyData({
|
||||||
|
writeOnly: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await brainyWriteOnly.init()
|
||||||
|
await brainyWithDirectReads.init()
|
||||||
|
await brainyNormal.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (brainyWriteOnly) {
|
||||||
|
await brainyWriteOnly.cleanup?.()
|
||||||
|
}
|
||||||
|
if (brainyWithDirectReads) {
|
||||||
|
await brainyWithDirectReads.cleanup?.()
|
||||||
|
}
|
||||||
|
if (brainyNormal) {
|
||||||
|
await brainyNormal.cleanup?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Configuration Validation', () => {
|
||||||
|
test('should accept allowDirectReads: true with writeOnly: true', () => {
|
||||||
|
expect(() => new BrainyData({
|
||||||
|
writeOnly: true,
|
||||||
|
allowDirectReads: true
|
||||||
|
})).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should accept allowDirectReads: false with writeOnly: true', () => {
|
||||||
|
expect(() => new BrainyData({
|
||||||
|
writeOnly: true,
|
||||||
|
allowDirectReads: false
|
||||||
|
})).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should accept allowDirectReads: true with writeOnly: false', () => {
|
||||||
|
expect(() => new BrainyData({
|
||||||
|
writeOnly: false,
|
||||||
|
allowDirectReads: true
|
||||||
|
})).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Write Operations (Should Always Work)', () => {
|
||||||
|
test('should allow add in all modes', async () => {
|
||||||
|
const testData = 'test string for embedding'
|
||||||
|
|
||||||
|
// All instances should be able to add data
|
||||||
|
const id1 = await brainyWriteOnly.add(testData)
|
||||||
|
const id2 = await brainyWithDirectReads.add(testData)
|
||||||
|
const id3 = await brainyNormal.add(testData)
|
||||||
|
|
||||||
|
expect(id1).toBeTruthy()
|
||||||
|
expect(id2).toBeTruthy()
|
||||||
|
expect(id3).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should allow add operations with metadata in all modes', async () => {
|
||||||
|
const testVector = new Array(384).fill(0.1)
|
||||||
|
const metadata1 = { name: 'test 1', type: 'entity' }
|
||||||
|
const metadata2 = { name: 'test 2', type: 'entity' }
|
||||||
|
|
||||||
|
// All instances should be able to add data with metadata
|
||||||
|
const id1 = await brainyWriteOnly.add(testVector, metadata1)
|
||||||
|
const id2 = await brainyWithDirectReads.add(testVector, metadata2)
|
||||||
|
|
||||||
|
expect(id1).toBeTruthy()
|
||||||
|
expect(id2).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Direct Read Operations', () => {
|
||||||
|
let testId: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add test data with metadata for testing
|
||||||
|
const testVector = new Array(384).fill(0.2)
|
||||||
|
const testMetadata = { name: 'direct read test', content: 'test content' }
|
||||||
|
testId = await brainyWithDirectReads.add(testVector, testMetadata)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('get() method', () => {
|
||||||
|
test('should work in write-only mode without allowDirectReads (legacy behavior)', async () => {
|
||||||
|
// Add data to write-only instance with metadata
|
||||||
|
const testVector = new Array(384).fill(0.3)
|
||||||
|
const id = await brainyWriteOnly.add(testVector, { name: 'legacy test' })
|
||||||
|
const result = await brainyWriteOnly.get(id)
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result?.metadata.name).toBe('legacy test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in write-only mode with allowDirectReads', async () => {
|
||||||
|
const result = await brainyWithDirectReads.get(testId)
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result?.metadata.name).toBe('direct read test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in normal mode', async () => {
|
||||||
|
const testVector = new Array(384).fill(0.4)
|
||||||
|
const id = await brainyNormal.add(testVector, { name: 'normal test' })
|
||||||
|
const result = await brainyNormal.get(id)
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result?.metadata.name).toBe('normal test')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('has() method', () => {
|
||||||
|
test('should fail in write-only mode without allowDirectReads', async () => {
|
||||||
|
await expect(brainyWriteOnly.has(testId))
|
||||||
|
.rejects.toThrow('Cannot perform has() operation: database is in write-only mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in write-only mode with allowDirectReads', async () => {
|
||||||
|
const exists = await brainyWithDirectReads.has(testId)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
|
||||||
|
const notExists = await brainyWithDirectReads.has('nonexistent-id')
|
||||||
|
expect(notExists).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in normal mode', async () => {
|
||||||
|
const testVector = new Array(384).fill(0.5)
|
||||||
|
const id = await brainyNormal.add(testVector, { name: 'has test' })
|
||||||
|
const exists = await brainyNormal.has(id)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exists() method', () => {
|
||||||
|
test('should fail in write-only mode without allowDirectReads', async () => {
|
||||||
|
await expect(brainyWriteOnly.exists(testId))
|
||||||
|
.rejects.toThrow('Cannot perform has() operation: database is in write-only mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in write-only mode with allowDirectReads', async () => {
|
||||||
|
const exists = await brainyWithDirectReads.exists(testId)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
|
||||||
|
const notExists = await brainyWithDirectReads.exists('nonexistent-id')
|
||||||
|
expect(notExists).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMetadata() method', () => {
|
||||||
|
test('should fail in write-only mode without allowDirectReads', async () => {
|
||||||
|
await expect(brainyWriteOnly.getMetadata(testId))
|
||||||
|
.rejects.toThrow('Cannot perform getMetadata() operation: database is in write-only mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in write-only mode with allowDirectReads', async () => {
|
||||||
|
const metadata = await brainyWithDirectReads.getMetadata(testId)
|
||||||
|
expect(metadata).toBeTruthy()
|
||||||
|
expect(metadata?.name).toBe('direct read test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should return null for nonexistent ID', async () => {
|
||||||
|
const metadata = await brainyWithDirectReads.getMetadata('nonexistent-id')
|
||||||
|
expect(metadata).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getBatch() method', () => {
|
||||||
|
test('should fail in write-only mode without allowDirectReads', async () => {
|
||||||
|
await expect(brainyWriteOnly.getBatch([testId]))
|
||||||
|
.rejects.toThrow('Cannot perform getBatch() operation: database is in write-only mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work in write-only mode with allowDirectReads', async () => {
|
||||||
|
const testVector2 = new Array(384).fill(0.6)
|
||||||
|
const id2 = await brainyWithDirectReads.add(testVector2, { name: 'batch test 2' })
|
||||||
|
const results = await brainyWithDirectReads.getBatch([testId, id2, 'nonexistent'])
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3)
|
||||||
|
expect(results[0]?.metadata.name).toBe('direct read test')
|
||||||
|
expect(results[1]?.metadata.name).toBe('batch test 2')
|
||||||
|
expect(results[2]).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle empty array', async () => {
|
||||||
|
const results = await brainyWithDirectReads.getBatch([])
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: getVerb() tests removed as the API may not be available in this version
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search Operations (Should Be Blocked)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Add some test data
|
||||||
|
const testVector = new Array(384).fill(0.7)
|
||||||
|
await brainyWithDirectReads.add(testVector, { name: 'search test', content: 'searchable content' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('search() should fail in write-only mode even with allowDirectReads', async () => {
|
||||||
|
await expect(brainyWithDirectReads.search('test'))
|
||||||
|
.rejects.toThrow('Cannot perform search operation: database is in write-only mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: similar() and query() methods may not be available in this version
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Real-World Use Cases', () => {
|
||||||
|
describe('Bluesky Service Pattern', () => {
|
||||||
|
test('should enable efficient deduplication in writer service', async () => {
|
||||||
|
// Simulate a Bluesky service processing messages
|
||||||
|
const processMessage = async (did: string, messageData: any) => {
|
||||||
|
// Check if profile already exists (direct storage lookup)
|
||||||
|
const existingProfile = await brainyWithDirectReads.get(did)
|
||||||
|
|
||||||
|
if (!existingProfile) {
|
||||||
|
// Only call external API for new DIDs
|
||||||
|
const profileData = { did, handle: `user-${did}`, displayName: 'Test User' }
|
||||||
|
const simpleVector = new Array(384).fill(0.1)
|
||||||
|
await brainyWithDirectReads.add(simpleVector, profileData, { id: did })
|
||||||
|
return { action: 'created', profile: profileData }
|
||||||
|
} else {
|
||||||
|
// Profile exists, skip API call
|
||||||
|
return { action: 'existing', profile: existingProfile.metadata }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process same DID twice
|
||||||
|
const result1 = await processMessage('did:test:123', { text: 'Hello' })
|
||||||
|
const result2 = await processMessage('did:test:123', { text: 'World' })
|
||||||
|
|
||||||
|
expect(result1.action).toBe('created')
|
||||||
|
expect(result2.action).toBe('existing')
|
||||||
|
expect(result2.profile.did).toBe('did:test:123')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GitHub Package Pattern', () => {
|
||||||
|
test('should enable efficient user processing', async () => {
|
||||||
|
const processUser = async (userId: string) => {
|
||||||
|
const userKey = `github_user_${userId}`
|
||||||
|
|
||||||
|
// Fast existence check (direct storage, no index)
|
||||||
|
if (await brainyWithDirectReads.has(userKey)) {
|
||||||
|
return { action: 'skipped', reason: 'already_processed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user - simulate API fetch and store
|
||||||
|
const userData = { id: userId, login: `user${userId}`, type: 'User' }
|
||||||
|
const simpleVector = new Array(384).fill(0.2)
|
||||||
|
await brainyWithDirectReads.add(simpleVector, userData, { id: userKey })
|
||||||
|
|
||||||
|
return { action: 'processed', user: userData }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process users
|
||||||
|
const result1 = await processUser('123')
|
||||||
|
const result2 = await processUser('123') // Duplicate
|
||||||
|
const result3 = await processUser('456') // New user
|
||||||
|
|
||||||
|
expect(result1.action).toBe('processed')
|
||||||
|
expect(result2.action).toBe('skipped')
|
||||||
|
expect(result3.action).toBe('processed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('General Writer Service Pattern', () => {
|
||||||
|
test('should support optimal entity processing', async () => {
|
||||||
|
const processEntity = async (id: string, data: any) => {
|
||||||
|
// Fast existence check using direct storage
|
||||||
|
const existing = await brainyWithDirectReads.get(id)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing entity
|
||||||
|
return { action: 'updated', existing: existing.metadata, new: data }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New entity - store it
|
||||||
|
const simpleVector = new Array(384).fill(0.3)
|
||||||
|
await brainyWithDirectReads.add(simpleVector, data, { id })
|
||||||
|
return { action: 'created', entity: data }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the pattern
|
||||||
|
const entity1 = { name: 'Entity 1', type: 'test' }
|
||||||
|
const entity1Updated = { name: 'Entity 1 Updated', type: 'test' }
|
||||||
|
|
||||||
|
const result1 = await processEntity('entity-1', entity1)
|
||||||
|
const result2 = await processEntity('entity-1', entity1Updated)
|
||||||
|
|
||||||
|
expect(result1.action).toBe('created')
|
||||||
|
expect(result2.action).toBe('updated')
|
||||||
|
expect(result2.existing.name).toBe('Entity 1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
test('should provide clear error messages for blocked operations', async () => {
|
||||||
|
await expect(brainyWriteOnly.has('test'))
|
||||||
|
.rejects.toThrow('Enable allowDirectReads for direct storage operations')
|
||||||
|
|
||||||
|
await expect(brainyWithDirectReads.search('test'))
|
||||||
|
.rejects.toThrow('Direct storage operations (get, has, exists, getMetadata, getBatch, getVerb) are allowed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle invalid IDs gracefully', async () => {
|
||||||
|
await expect(brainyWithDirectReads.get(null as any))
|
||||||
|
.rejects.toThrow('ID cannot be null or undefined')
|
||||||
|
|
||||||
|
await expect(brainyWithDirectReads.has(undefined as any))
|
||||||
|
.rejects.toThrow('ID cannot be null or undefined')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle storage errors gracefully', async () => {
|
||||||
|
// Test with non-existent IDs
|
||||||
|
expect(await brainyWithDirectReads.has('non-existent')).toBe(false)
|
||||||
|
expect(await brainyWithDirectReads.get('non-existent')).toBeNull()
|
||||||
|
expect(await brainyWithDirectReads.getMetadata('non-existent')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
203
vitest.config.ts
Normal file
203
vitest.config.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// Default configuration
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./tests/test-setup.ts', './tests/setup.ts'],
|
||||||
|
testTimeout: 120000, // 120 seconds for TensorFlow operations
|
||||||
|
hookTimeout: 120000,
|
||||||
|
// Run tests in parallel with limited pool
|
||||||
|
pool: 'forks',
|
||||||
|
poolOptions: {
|
||||||
|
forks: {
|
||||||
|
singleFork: true, // Run all tests in a single fork to reduce memory usage
|
||||||
|
isolate: false, // Don't isolate tests to reduce overhead
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Limit concurrent tests to reduce memory usage
|
||||||
|
maxConcurrency: 1,
|
||||||
|
// Clear mocks between tests
|
||||||
|
clearMocks: true,
|
||||||
|
restoreMocks: true,
|
||||||
|
// Include test files
|
||||||
|
include: ['tests/**/*.{test,spec}.{js,ts}'],
|
||||||
|
// Default environment
|
||||||
|
environment: 'node',
|
||||||
|
// Exclude old test files
|
||||||
|
exclude: [
|
||||||
|
'node_modules/**',
|
||||||
|
'dist/**',
|
||||||
|
'scripts/**',
|
||||||
|
'examples/**',
|
||||||
|
'*.js' // Exclude old JS test files in root
|
||||||
|
],
|
||||||
|
// Add environment options to help with TextEncoder issues
|
||||||
|
environmentOptions: {
|
||||||
|
env: {
|
||||||
|
FORCE_PATCHED_PLATFORM: 'true'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Configure reporters for different output formats
|
||||||
|
reporters: [
|
||||||
|
// Default reporter for basic progress during test run
|
||||||
|
[
|
||||||
|
'default',
|
||||||
|
{
|
||||||
|
summary: true,
|
||||||
|
reportSummary: true,
|
||||||
|
// Show test titles for all tests
|
||||||
|
successfulTestOnly: false,
|
||||||
|
// Show a compact output
|
||||||
|
outputFile: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// JSON reporter for machine-readable output (can be used for CI/CD)
|
||||||
|
[
|
||||||
|
'json',
|
||||||
|
{
|
||||||
|
outputFile: './tests/results/test-results.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
// Configure output for better visibility
|
||||||
|
silent: false,
|
||||||
|
// Configure error display for better readability
|
||||||
|
bail: 0,
|
||||||
|
// Disable coverage reports by default to reduce noise
|
||||||
|
coverage: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
// Don't show test statistics to reduce noise
|
||||||
|
logHeapUsage: false,
|
||||||
|
// Hide skipped tests to reduce noise
|
||||||
|
hideSkippedTests: true,
|
||||||
|
// Only show stack traces for failed tests
|
||||||
|
printConsoleTrace: false,
|
||||||
|
// Show test timing information in the summary
|
||||||
|
// showTimer: true,
|
||||||
|
// Aggressively filter out console output to only show test progress
|
||||||
|
onConsoleLog: (log: string, type: 'stdout' | 'stderr'): false | void => {
|
||||||
|
// For stdout, only allow critical error messages and test progress indicators
|
||||||
|
if (type === 'stdout') {
|
||||||
|
// Only allow through explicit test-related messages and critical errors
|
||||||
|
const allowedPatterns = [
|
||||||
|
'Error:',
|
||||||
|
'FAIL',
|
||||||
|
'PASS',
|
||||||
|
'WARNING:',
|
||||||
|
'test result',
|
||||||
|
'Test Files',
|
||||||
|
'Tests',
|
||||||
|
'Start at',
|
||||||
|
'Duration',
|
||||||
|
'✓',
|
||||||
|
'✗',
|
||||||
|
'running',
|
||||||
|
'suite'
|
||||||
|
]
|
||||||
|
|
||||||
|
// If the log doesn't contain any allowed pattern, filter it out
|
||||||
|
if (!allowedPatterns.some((pattern) => log.includes(pattern))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For stderr, only show actual errors
|
||||||
|
if (
|
||||||
|
type === 'stderr' &&
|
||||||
|
!log.includes('Error:') &&
|
||||||
|
!log.includes('FAIL')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded list of noise patterns to filter out
|
||||||
|
const noisePatterns: string[] = [
|
||||||
|
// Original patterns
|
||||||
|
'Brainy:',
|
||||||
|
'TensorFlow',
|
||||||
|
'Universal Sentence Encoder',
|
||||||
|
'Using file system storage',
|
||||||
|
'Using WebGL backend',
|
||||||
|
'Platform node',
|
||||||
|
'The kernel',
|
||||||
|
'for backend',
|
||||||
|
'is already registered',
|
||||||
|
'Hi there 👋',
|
||||||
|
'backend registration',
|
||||||
|
'webgl',
|
||||||
|
'cpu',
|
||||||
|
'Could not get context',
|
||||||
|
'Retrying',
|
||||||
|
'Skipping noun',
|
||||||
|
'due to dimension mismatch',
|
||||||
|
'Successfully loaded',
|
||||||
|
'model loaded',
|
||||||
|
'module structure',
|
||||||
|
'No default export',
|
||||||
|
'Using sentenceEncoderModule',
|
||||||
|
'Loading',
|
||||||
|
'Applying',
|
||||||
|
'Overwriting',
|
||||||
|
'Applied',
|
||||||
|
'running in Node.js environment',
|
||||||
|
'Pre-loading',
|
||||||
|
'has already been set',
|
||||||
|
// Additional patterns to filter out common console.log statements from tests
|
||||||
|
'Attempting to add',
|
||||||
|
'Successfully added',
|
||||||
|
'Test API server running',
|
||||||
|
'Text content not found',
|
||||||
|
'Searching for text',
|
||||||
|
'Expected ID:',
|
||||||
|
'Search returned',
|
||||||
|
'First result ID:',
|
||||||
|
'All result IDs:',
|
||||||
|
'Could not find result',
|
||||||
|
'Found result with matching ID:',
|
||||||
|
'console.log',
|
||||||
|
'console.info',
|
||||||
|
'console.debug'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Return false (don't show) if log contains any noise pattern
|
||||||
|
if (noisePatterns.some((pattern) => log.includes(pattern))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional filtering for common debug output patterns
|
||||||
|
if (
|
||||||
|
log.includes('Searching') ||
|
||||||
|
log.includes('Found') ||
|
||||||
|
log.includes('Created') ||
|
||||||
|
log.includes('Loaded') ||
|
||||||
|
log.includes('Processing') ||
|
||||||
|
log.includes('Initializing') ||
|
||||||
|
log.includes('Starting') ||
|
||||||
|
log.includes('Completed') ||
|
||||||
|
log.includes('Finished')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined // Show the log if it passes all filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a custom reporter configuration for a cleaner output
|
||||||
|
// outputDiffLines: 5, // Limit diff output lines for cleaner error reports
|
||||||
|
// outputFileMaxLines: 40, // Limit file output lines for cleaner error reports
|
||||||
|
// outputTruncateLength: 80 // Truncate long output lines
|
||||||
|
},
|
||||||
|
// Resolve configuration for proper module handling
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': './src',
|
||||||
|
'@tests': './tests'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Define different configurations for different environments
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"test"'
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue