brainy/tests/vfs/tree-operations.test.ts
David Snelling 72590d52b0 feat: add tree-aware VFS methods to prevent recursion in file explorers
Add safe tree operations that guarantee no directory appears as its own child:
- getDirectChildren() returns only immediate children
- getTreeStructure() builds safe tree with recursion protection
- getDescendants() gets all descendants efficiently
- inspect() provides comprehensive path information

Also includes VFSTreeUtils for building and validating tree structures.

This resolves the common infinite recursion issue when building file
explorers, as discovered by the Soulcraft Studio team.

Co-Authored-By: User <noreply@user.local>
2025-09-26 10:17:59 -07:00

337 lines
No EOL
12 KiB
TypeScript

/**
* VFS Tree Operations Tests
* Ensures tree methods prevent recursion and work correctly
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
import { VFSTreeUtils } from '../../src/vfs/TreeUtils.js'
describe('VFS Tree Operations', () => {
let brain: Brainy
let vfs: VirtualFileSystem
beforeEach(async () => {
brain = new Brainy()
await brain.init({
storage: {
type: 'memory' // Use in-memory storage for tests
}
})
vfs = new VirtualFileSystem(brain)
await vfs.init()
})
describe('Critical: No Self-Inclusion Bug', () => {
it('should NEVER return a directory as its own child', async () => {
// Create test structure
await vfs.mkdir('/test-dir')
await vfs.writeFile('/test-dir/file1.txt', 'content1')
await vfs.writeFile('/test-dir/file2.txt', 'content2')
await vfs.mkdir('/test-dir/subdir')
// Test getDirectChildren - should NOT include /test-dir itself
const children = await vfs.getDirectChildren('/test-dir')
// Critical assertion - directory should NOT be in its own children
const selfIncluded = children.some(child => child.metadata.path === '/test-dir')
expect(selfIncluded).toBe(false)
// Should have exactly 3 children
expect(children.length).toBe(3)
expect(children.map(c => c.metadata.name).sort()).toEqual(['file1.txt', 'file2.txt', 'subdir'])
})
it('should handle root directory correctly', async () => {
await vfs.mkdir('/dir1')
await vfs.mkdir('/dir2')
const rootChildren = await vfs.getDirectChildren('/')
// Root should not be in its own children
const rootInChildren = rootChildren.some(child => child.metadata.path === '/')
expect(rootInChildren).toBe(false)
expect(rootChildren.length).toBe(2)
})
it('should prevent recursion in tree structure', async () => {
// Create deeper structure
await vfs.mkdir('/a')
await vfs.mkdir('/a/b')
await vfs.mkdir('/a/b/c')
await vfs.writeFile('/a/b/c/file.txt', 'deep')
const tree = await vfs.getTreeStructure('/a')
// Validate no cycles
const validation = VFSTreeUtils.validateTree(tree)
expect(validation.valid).toBe(true)
expect(validation.errors).toHaveLength(0)
// Check tree structure
expect(tree.path).toBe('/a')
expect(tree.children).toBeDefined()
expect(tree.children!.length).toBe(1) // Only 'b'
expect(tree.children![0].name).toBe('b')
expect(tree.children![0].children!.length).toBe(1) // Only 'c'
expect(tree.children![0].children![0].name).toBe('c')
})
})
describe('getDirectChildren', () => {
it('should return only immediate children', async () => {
await vfs.mkdir('/parent')
await vfs.mkdir('/parent/child1')
await vfs.mkdir('/parent/child2')
await vfs.mkdir('/parent/child1/grandchild')
await vfs.writeFile('/parent/file.txt', 'test')
const children = await vfs.getDirectChildren('/parent')
expect(children.length).toBe(3) // child1, child2, file.txt
expect(children.map(c => c.metadata.name).sort()).toEqual(['child1', 'child2', 'file.txt'])
// Should NOT include grandchild
const hasGrandchild = children.some(c => c.metadata.name === 'grandchild')
expect(hasGrandchild).toBe(false)
})
it('should throw error for non-directory', async () => {
await vfs.writeFile('/file.txt', 'content')
await expect(vfs.getDirectChildren('/file.txt')).rejects.toThrow('Not a directory')
})
})
describe('getTreeStructure', () => {
it('should build correct tree with depth limit', async () => {
// Create multi-level structure
await vfs.mkdir('/root')
await vfs.mkdir('/root/level1')
await vfs.mkdir('/root/level1/level2')
await vfs.mkdir('/root/level1/level2/level3')
await vfs.writeFile('/root/level1/level2/level3/deep.txt', 'very deep')
const tree = await vfs.getTreeStructure('/root', { maxDepth: 2 })
expect(tree.children).toBeDefined()
expect(tree.children![0].name).toBe('level1')
expect(tree.children![0].children![0].name).toBe('level2')
// Level 3 should be cut off due to maxDepth
expect(tree.children![0].children![0].children).toBeUndefined()
})
it('should sort tree nodes correctly', async () => {
await vfs.mkdir('/sorted')
await vfs.writeFile('/sorted/zebra.txt', 'z')
await vfs.writeFile('/sorted/apple.txt', 'a')
await vfs.mkdir('/sorted/banana')
await vfs.mkdir('/sorted/cherry')
const tree = await vfs.getTreeStructure('/sorted', { sort: 'name' })
// Directories should come first, then files, both sorted by name
const names = tree.children!.map(c => c.name)
expect(names).toEqual(['banana', 'cherry', 'apple.txt', 'zebra.txt'])
})
it('should filter hidden files', async () => {
await vfs.mkdir('/hidden-test')
await vfs.writeFile('/hidden-test/.hidden', 'secret')
await vfs.writeFile('/hidden-test/visible.txt', 'public')
await vfs.mkdir('/hidden-test/.secret-dir')
const tree = await vfs.getTreeStructure('/hidden-test', { includeHidden: false })
expect(tree.children!.length).toBe(1)
expect(tree.children![0].name).toBe('visible.txt')
})
})
describe('getDescendants', () => {
it('should return all descendants flat', async () => {
await vfs.mkdir('/desc')
await vfs.mkdir('/desc/a')
await vfs.mkdir('/desc/a/b')
await vfs.writeFile('/desc/a/b/file.txt', 'deep')
await vfs.writeFile('/desc/file1.txt', 'top')
const descendants = await vfs.getDescendants('/desc')
expect(descendants.length).toBe(4) // a, a/b, a/b/file.txt, file1.txt
// Should NOT include /desc itself by default
const hasSelf = descendants.some(d => d.metadata.path === '/desc')
expect(hasSelf).toBe(false)
})
it('should include ancestor when requested', async () => {
await vfs.mkdir('/ancestor')
await vfs.mkdir('/ancestor/child')
const withAncestor = await vfs.getDescendants('/ancestor', { includeAncestor: true })
const withoutAncestor = await vfs.getDescendants('/ancestor', { includeAncestor: false })
expect(withAncestor.length).toBe(2) // ancestor + child
expect(withoutAncestor.length).toBe(1) // only child
})
it('should filter by type', async () => {
await vfs.mkdir('/typed')
await vfs.mkdir('/typed/dir1')
await vfs.mkdir('/typed/dir2')
await vfs.writeFile('/typed/file1.txt', 'f1')
await vfs.writeFile('/typed/file2.txt', 'f2')
const dirsOnly = await vfs.getDescendants('/typed', { type: 'directory' })
const filesOnly = await vfs.getDescendants('/typed', { type: 'file' })
expect(dirsOnly.length).toBe(2)
expect(filesOnly.length).toBe(2)
expect(dirsOnly.every(d => d.metadata.vfsType === 'directory')).toBe(true)
expect(filesOnly.every(f => f.metadata.vfsType === 'file')).toBe(true)
})
})
describe('inspect', () => {
it('should return comprehensive information', async () => {
await vfs.mkdir('/inspect-test')
await vfs.mkdir('/inspect-test/child1')
await vfs.writeFile('/inspect-test/file.txt', 'content')
const result = await vfs.inspect('/inspect-test/child1')
expect(result.node.metadata.name).toBe('child1')
expect(result.node.metadata.path).toBe('/inspect-test/child1')
expect(result.children).toEqual([]) // Empty directory
expect(result.parent).toBeDefined()
expect(result.parent!.metadata.path).toBe('/inspect-test')
expect(result.stats).toBeDefined()
expect(result.stats.isDirectory()).toBe(true)
})
it('should handle root directory specially', async () => {
await vfs.mkdir('/root-child')
const result = await vfs.inspect('/')
expect(result.node.metadata.path).toBe('/')
expect(result.parent).toBeNull() // Root has no parent
expect(result.children.length).toBeGreaterThan(0)
expect(result.stats.isDirectory()).toBe(true)
})
})
describe('VFSTreeUtils', () => {
it('should validate tree structure correctly', async () => {
// Create a valid tree
await vfs.mkdir('/valid')
await vfs.mkdir('/valid/sub1')
await vfs.mkdir('/valid/sub2')
const tree = await vfs.getTreeStructure('/valid')
const validation = VFSTreeUtils.validateTree(tree)
expect(validation.valid).toBe(true)
expect(validation.errors).toHaveLength(0)
})
it('should detect cycles in tree', () => {
// Manually create an invalid tree with cycle
const childNode: any = {
name: 'child',
path: '/root/child',
type: 'directory' as const,
children: []
}
const invalidTree = {
name: 'root',
path: '/root',
type: 'directory' as const,
children: [childNode]
}
// Create a cycle by adding the child back to itself
childNode.children.push(childNode) // Child contains itself = cycle
const validation = VFSTreeUtils.validateTree(invalidTree)
expect(validation.valid).toBe(false)
expect(validation.errors.length).toBeGreaterThan(0)
// The error could be either "Cycle detected" or "Directory contains itself"
const hasExpectedError = validation.errors.some(e =>
e.includes('Cycle detected') || e.includes('Directory contains itself')
)
expect(hasExpectedError).toBe(true)
})
it('should detect self-inclusion', () => {
const badTree = {
name: 'dir',
path: '/dir',
type: 'directory' as const,
children: [{
name: 'dir',
path: '/dir', // Same as parent!
type: 'directory' as const
}]
}
const validation = VFSTreeUtils.validateTree(badTree)
expect(validation.valid).toBe(false)
expect(validation.errors).toContainEqual('Directory contains itself: /dir')
})
it('should calculate tree statistics', async () => {
await vfs.mkdir('/stats')
await vfs.mkdir('/stats/dir1')
await vfs.mkdir('/stats/dir2')
await vfs.writeFile('/stats/file1.txt', 'a'.repeat(100))
await vfs.writeFile('/stats/dir1/file2.txt', 'b'.repeat(200))
const tree = await vfs.getTreeStructure('/stats')
const stats = VFSTreeUtils.getTreeStats(tree)
expect(stats.totalNodes).toBe(5) // /stats (root), dir1, dir2, file1, file2
expect(stats.directories).toBe(3) // /stats, dir1, dir2
expect(stats.files).toBe(2)
expect(stats.maxDepth).toBe(2)
expect(stats.totalSize).toBe(300)
})
})
describe('Performance with large trees', () => {
it('should handle large directory structures efficiently', async () => {
// Create a reasonably large structure
const dirs = 10
const filesPerDir = 5
await vfs.mkdir('/perf-test')
for (let i = 0; i < dirs; i++) {
await vfs.mkdir(`/perf-test/dir${i}`)
for (let j = 0; j < filesPerDir; j++) {
await vfs.writeFile(`/perf-test/dir${i}/file${j}.txt`, `content-${i}-${j}`)
}
}
const startTime = Date.now()
const tree = await vfs.getTreeStructure('/perf-test')
const elapsed = Date.now() - startTime
// Should complete reasonably fast (under 1 second for this size)
expect(elapsed).toBeLessThan(1000)
// Validate structure
expect(tree.children!.length).toBe(dirs)
expect(tree.children![0].children!.length).toBe(filesPerDir)
// No cycles
const validation = VFSTreeUtils.validateTree(tree)
expect(validation.valid).toBe(true)
})
})
})