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>
337 lines
No EOL
12 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}) |