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>
This commit is contained in:
parent
0b20fd24da
commit
72590d52b0
7 changed files with 1261 additions and 4 deletions
|
|
@ -49,6 +49,18 @@ await vfs.addRelationship('/docs/spec.md', '/projects/my-app/', 'implements')
|
|||
|
||||
## Core Features
|
||||
|
||||
### 🆕 Tree Operations (Prevents Recursion Issues)
|
||||
|
||||
**NEW: Safe tree operations for building file explorers:**
|
||||
- **`getDirectChildren(path)`** - Returns only immediate children, never the parent
|
||||
- **`getTreeStructure(path, options)`** - Builds complete tree with recursion protection
|
||||
- **`getDescendants(path, options)`** - Gets all descendants efficiently
|
||||
- **`inspect(path)`** - Comprehensive info with parent, children, and stats
|
||||
|
||||
See [Building File Explorers Guide](building-file-explorers.md) for complete documentation on avoiding common recursion pitfalls.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 📁 Full Filesystem API
|
||||
|
||||
All the operations you expect from a filesystem:
|
||||
|
|
|
|||
|
|
@ -99,16 +99,71 @@ await vfs.unlink('/documents/notes.txt')
|
|||
#### Directory Operations
|
||||
|
||||
```typescript
|
||||
// Create directory (recursive by default)
|
||||
// Create directory
|
||||
await vfs.mkdir(path: string, options?: MkdirOptions): Promise<void>
|
||||
|
||||
// Remove directory
|
||||
await vfs.rmdir(path: string, options?: RmdirOptions): Promise<void>
|
||||
// Remove directory (must be empty unless recursive)
|
||||
await vfs.rmdir(path: string, options?: { recursive?: boolean }): Promise<void>
|
||||
|
||||
// List directory contents
|
||||
await vfs.readdir(path: string, options?: ReaddirOptions): Promise<string[] | Dirent[]>
|
||||
await vfs.readdir(path: string, options?: ReaddirOptions): Promise<string[] | VFSDirent[]>
|
||||
```
|
||||
|
||||
#### Tree Operations (NEW - Safe for File Explorers) 🆕
|
||||
|
||||
```typescript
|
||||
// Get direct children only - GUARANTEED no self-inclusion
|
||||
await vfs.getDirectChildren(path: string): Promise<VFSEntity[]>
|
||||
|
||||
// Build complete tree structure - prevents recursion issues
|
||||
await vfs.getTreeStructure(path: string, options?: {
|
||||
maxDepth?: number // Limit tree depth
|
||||
includeHidden?: boolean // Include hidden files
|
||||
sort?: 'name' | 'modified' | 'size' // Sort order
|
||||
}): Promise<TreeNode>
|
||||
|
||||
// Get all descendants (flat list)
|
||||
await vfs.getDescendants(path: string, options?: {
|
||||
includeAncestor?: boolean // Include the directory itself
|
||||
type?: 'file' | 'directory' // Filter by type
|
||||
}): Promise<VFSEntity[]>
|
||||
|
||||
// Get comprehensive info about a path
|
||||
await vfs.inspect(path: string): Promise<{
|
||||
node: VFSEntity // The entity itself
|
||||
children: VFSEntity[] // Direct children only
|
||||
parent: VFSEntity | null // Parent directory
|
||||
stats: VFSStats // File statistics
|
||||
}>
|
||||
```
|
||||
|
||||
**Example - Building a File Explorer:**
|
||||
```typescript
|
||||
// ✅ CORRECT - Safe from recursion
|
||||
const children = await vfs.getDirectChildren('/my-dir')
|
||||
// children will NEVER include /my-dir itself
|
||||
|
||||
// Get structured tree
|
||||
const tree = await vfs.getTreeStructure('/my-dir', {
|
||||
maxDepth: 3,
|
||||
includeHidden: false,
|
||||
sort: 'name'
|
||||
})
|
||||
|
||||
// Get all files in directory
|
||||
const allFiles = await vfs.getDescendants('/my-dir', {
|
||||
type: 'file' // Only files, no directories
|
||||
})
|
||||
|
||||
// Inspect a path
|
||||
const info = await vfs.inspect('/my-dir/file.txt')
|
||||
console.log(info.parent.metadata.path) // '/my-dir'
|
||||
console.log(info.stats.size) // File size
|
||||
```
|
||||
|
||||
⚠️ **See [Building File Explorers Guide](building-file-explorers.md)** for detailed examples and how to avoid common recursion pitfalls.
|
||||
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Create nested directories
|
||||
|
|
|
|||
329
docs/vfs/building-file-explorers.md
Normal file
329
docs/vfs/building-file-explorers.md
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
# Building File Explorers with Brainy VFS
|
||||
|
||||
## Overview
|
||||
|
||||
When building file explorers, tree views, or any UI that displays directory structures, it's critical to avoid common pitfalls that can lead to infinite recursion. This guide shows you how to safely build file explorers using Brainy VFS's tree-aware methods.
|
||||
|
||||
## ⚠️ The Self-Inclusion Problem
|
||||
|
||||
### What Goes Wrong
|
||||
|
||||
When building tree UIs, developers often make this mistake:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Can cause infinite recursion!
|
||||
function buildTree(allNodes, parentPath) {
|
||||
const children = allNodes.filter(node => {
|
||||
// This accidentally includes the parent itself!
|
||||
return node.path.startsWith(parentPath)
|
||||
})
|
||||
|
||||
// If parentPath = '/dir', this includes '/dir' itself
|
||||
// Leading to: dir -> dir -> dir -> ... (infinite loop)
|
||||
}
|
||||
```
|
||||
|
||||
### Why It Happens
|
||||
|
||||
Directories are stored as nodes with paths like `/brainy-data`. When filtering for "children of `/brainy-data`", naive string matching will match the directory itself because:
|
||||
- `/brainy-data` starts with `/brainy-data` ✓
|
||||
- This causes the directory to appear as its own child
|
||||
- UI frameworks then render infinitely nested directories
|
||||
|
||||
## ✅ The Solution: Use Tree-Aware Methods
|
||||
|
||||
Brainy VFS provides safe, tree-aware methods that prevent these issues:
|
||||
|
||||
### Method 1: Use `getDirectChildren()` (Recommended)
|
||||
|
||||
```typescript
|
||||
import { Brainy, VirtualFileSystem } from '@soulcraft/brainy'
|
||||
|
||||
const brain = new Brainy()
|
||||
await brain.init()
|
||||
const vfs = new VirtualFileSystem(brain)
|
||||
await vfs.init()
|
||||
|
||||
// ✅ CORRECT - Returns only direct children, never the parent
|
||||
const children = await vfs.getDirectChildren('/brainy-data')
|
||||
|
||||
// Build your UI with confidence
|
||||
children.forEach(child => {
|
||||
console.log(child.metadata.name) // 'file.txt', 'subdir', etc.
|
||||
// child.metadata.path will NEVER be '/brainy-data' itself
|
||||
})
|
||||
```
|
||||
|
||||
### Method 2: Use `getTreeStructure()` for Complete Trees
|
||||
|
||||
```typescript
|
||||
// ✅ Get a properly structured tree - no recursion possible
|
||||
const tree = await vfs.getTreeStructure('/brainy-data', {
|
||||
maxDepth: 3, // Limit depth for performance
|
||||
includeHidden: false, // Skip hidden files
|
||||
sort: 'name' // Sort by name
|
||||
})
|
||||
|
||||
// Tree is guaranteed to be valid:
|
||||
// {
|
||||
// name: 'brainy-data',
|
||||
// path: '/brainy-data',
|
||||
// type: 'directory',
|
||||
// children: [
|
||||
// { name: 'file.txt', path: '/brainy-data/file.txt', type: 'file' },
|
||||
// { name: 'subdir', path: '/brainy-data/subdir', type: 'directory', children: [...] }
|
||||
// ]
|
||||
// }
|
||||
```
|
||||
|
||||
### Method 3: Use `inspect()` for Single-Level Details
|
||||
|
||||
```typescript
|
||||
// ✅ Get comprehensive info about a path
|
||||
const info = await vfs.inspect('/brainy-data/subdir')
|
||||
|
||||
// Returns:
|
||||
// {
|
||||
// node: { ... }, // The directory itself
|
||||
// children: [ ... ], // Direct children only
|
||||
// parent: { ... }, // Parent directory
|
||||
// stats: { ... } // File statistics
|
||||
// }
|
||||
```
|
||||
|
||||
## Building a React File Explorer
|
||||
|
||||
Here's a complete example using React:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { VirtualFileSystem } from '@soulcraft/brainy'
|
||||
|
||||
interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
function FileExplorer({ vfs }: { vfs: VirtualFileSystem }) {
|
||||
const [tree, setTree] = useState<FileNode | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadTree()
|
||||
}, [])
|
||||
|
||||
async function loadTree() {
|
||||
// ✅ Use getTreeStructure - guaranteed no recursion
|
||||
const treeData = await vfs.getTreeStructure('/', {
|
||||
maxDepth: 2, // Initially load only 2 levels
|
||||
sort: 'name'
|
||||
})
|
||||
setTree(treeData)
|
||||
}
|
||||
|
||||
async function toggleDirectory(path: string) {
|
||||
if (expanded.has(path)) {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(path)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Load children on demand
|
||||
const children = await vfs.getDirectChildren(path)
|
||||
|
||||
// Update tree with new children
|
||||
// (Implementation depends on your state management)
|
||||
|
||||
setExpanded(prev => new Set([...prev, path]))
|
||||
}
|
||||
}
|
||||
|
||||
return <TreeView node={tree} onToggle={toggleDirectory} expanded={expanded} />
|
||||
}
|
||||
|
||||
function TreeView({ node, onToggle, expanded }) {
|
||||
if (!node) return null
|
||||
|
||||
const isExpanded = expanded.has(node.path)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div onClick={() => node.type === 'directory' && onToggle(node.path)}>
|
||||
{node.type === 'directory' ? (isExpanded ? '📂' : '📁') : '📄'}
|
||||
{node.name}
|
||||
</div>
|
||||
{isExpanded && node.children && (
|
||||
<div style={{ paddingLeft: 20 }}>
|
||||
{node.children.map(child => (
|
||||
<TreeView
|
||||
key={child.path}
|
||||
node={child}
|
||||
onToggle={onToggle}
|
||||
expanded={expanded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Manual Tree Building (If Needed)
|
||||
|
||||
If you must build trees manually from flat lists, use the `VFSTreeUtils`:
|
||||
|
||||
```typescript
|
||||
import { VFSTreeUtils } from '@soulcraft/brainy/vfs'
|
||||
|
||||
// Get all entities somehow
|
||||
const allEntities = await vfs.getDescendants('/root')
|
||||
|
||||
// ✅ Build safe tree structure - handles edge cases automatically
|
||||
const tree = VFSTreeUtils.buildTree(allEntities, '/root', {
|
||||
maxDepth: 5,
|
||||
includeHidden: true,
|
||||
sort: 'modified'
|
||||
})
|
||||
|
||||
// Validate the tree (optional but recommended)
|
||||
const validation = VFSTreeUtils.validateTree(tree)
|
||||
if (!validation.valid) {
|
||||
console.error('Tree has issues:', validation.errors)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Lazy Loading
|
||||
|
||||
```typescript
|
||||
class LazyFileExplorer {
|
||||
private vfs: VirtualFileSystem
|
||||
private loadedPaths = new Set<string>()
|
||||
|
||||
async getNode(path: string) {
|
||||
if (!this.loadedPaths.has(path)) {
|
||||
// Use inspect for single-node details
|
||||
const info = await this.vfs.inspect(path)
|
||||
this.loadedPaths.add(path)
|
||||
return info
|
||||
}
|
||||
// Return from cache...
|
||||
}
|
||||
|
||||
async expandNode(path: string) {
|
||||
// Use getDirectChildren for lazy expansion
|
||||
const children = await this.vfs.getDirectChildren(path)
|
||||
return children
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Search Within Tree
|
||||
|
||||
```typescript
|
||||
async function searchInDirectory(vfs: VirtualFileSystem, dirPath: string, query: string) {
|
||||
// Get all descendants efficiently
|
||||
const allFiles = await vfs.getDescendants(dirPath, {
|
||||
type: 'file' // Only files, skip directories
|
||||
})
|
||||
|
||||
// Filter by name
|
||||
return allFiles.filter(file =>
|
||||
file.metadata.name.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Calculate Directory Size
|
||||
|
||||
```typescript
|
||||
async function getDirectorySize(vfs: VirtualFileSystem, dirPath: string) {
|
||||
const descendants = await vfs.getDescendants(dirPath, {
|
||||
type: 'file' // Only count files
|
||||
})
|
||||
|
||||
return descendants.reduce((total, file) =>
|
||||
total + (file.metadata.size || 0), 0
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Your File Explorer
|
||||
|
||||
Always test for the self-inclusion bug:
|
||||
|
||||
```typescript
|
||||
import { expect } from 'vitest'
|
||||
|
||||
test('directory should not be its own child', async () => {
|
||||
const children = await vfs.getDirectChildren('/test-dir')
|
||||
|
||||
// Critical assertion
|
||||
const selfIncluded = children.some(child =>
|
||||
child.metadata.path === '/test-dir'
|
||||
)
|
||||
expect(selfIncluded).toBe(false)
|
||||
})
|
||||
|
||||
test('tree should have no cycles', async () => {
|
||||
const tree = await vfs.getTreeStructure('/test-dir')
|
||||
const validation = VFSTreeUtils.validateTree(tree)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.errors).toHaveLength(0)
|
||||
})
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use `maxDepth`** - Don't load entire trees at once
|
||||
2. **Implement virtual scrolling** for large directories
|
||||
3. **Cache tree structures** when possible
|
||||
4. **Use `getDirectChildren()` for on-demand loading**
|
||||
5. **Batch VFS operations** when building initial views
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have existing code with the recursion bug:
|
||||
|
||||
```typescript
|
||||
// ❌ OLD CODE (buggy)
|
||||
const children = allItems.filter(item => {
|
||||
const itemParent = getParentPath(item.path)
|
||||
return itemParent === dirPath // Might include dirPath itself!
|
||||
})
|
||||
|
||||
// ✅ NEW CODE (safe)
|
||||
const children = await vfs.getDirectChildren(dirPath)
|
||||
// That's it! No filtering needed, no edge cases to handle
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Tree-Safe Methods
|
||||
|
||||
- `getDirectChildren(path)` - Returns immediate children only
|
||||
- `getTreeStructure(path, options)` - Returns complete tree object
|
||||
- `getDescendants(path, options)` - Returns all descendants (flat)
|
||||
- `inspect(path)` - Returns node with children, parent, and stats
|
||||
|
||||
### Utility Functions
|
||||
|
||||
- `VFSTreeUtils.buildTree(entities, root, options)` - Build tree from flat list
|
||||
- `VFSTreeUtils.validateTree(tree)` - Check for cycles and errors
|
||||
- `VFSTreeUtils.getTreeStats(tree)` - Calculate statistics
|
||||
- `VFSTreeUtils.getDirectChildren(entities, parent)` - Filter safely
|
||||
|
||||
## Summary
|
||||
|
||||
- **Never** filter children by simple string matching on paths
|
||||
- **Always** use VFS's tree-aware methods (`getDirectChildren`, `getTreeStructure`, etc.)
|
||||
- **Test** for self-inclusion and cycles
|
||||
- **Validate** trees when building manually
|
||||
|
||||
By following these guidelines, you'll build robust file explorers that never experience infinite recursion issues.
|
||||
358
src/vfs/TreeUtils.ts
Normal file
358
src/vfs/TreeUtils.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/**
|
||||
* VFS Tree Utilities
|
||||
* Provides safe tree operations that prevent common recursion issues
|
||||
*/
|
||||
|
||||
import { VFSEntity, VFSDirent } from './types.js'
|
||||
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
entityId?: string
|
||||
children?: TreeNode[]
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
export interface TreeOptions {
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
filter?: (node: VFSEntity) => boolean
|
||||
sort?: 'name' | 'modified' | 'size'
|
||||
expandAll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree utility functions for VFS
|
||||
* These functions ensure proper tree structure without recursion issues
|
||||
*/
|
||||
export class VFSTreeUtils {
|
||||
/**
|
||||
* Build a safe tree structure from VFS entities
|
||||
* Guarantees no directory appears as its own child
|
||||
*/
|
||||
static buildTree(
|
||||
entities: VFSEntity[],
|
||||
rootPath: string = '/',
|
||||
options: TreeOptions = {}
|
||||
): TreeNode {
|
||||
const pathToEntity = new Map<string, VFSEntity>()
|
||||
const pathToNode = new Map<string, TreeNode>()
|
||||
|
||||
// First pass: index all entities by path
|
||||
for (const entity of entities) {
|
||||
const path = entity.metadata.path
|
||||
|
||||
// Critical: Skip if entity IS the root we're building from
|
||||
if (path === rootPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
pathToEntity.set(path, entity)
|
||||
}
|
||||
|
||||
// Create root node
|
||||
const rootNode: TreeNode = {
|
||||
name: rootPath === '/' ? 'root' : rootPath.split('/').pop()!,
|
||||
path: rootPath,
|
||||
type: 'directory',
|
||||
children: []
|
||||
}
|
||||
pathToNode.set(rootPath, rootNode)
|
||||
|
||||
// Second pass: build tree structure
|
||||
const sortedPaths = Array.from(pathToEntity.keys()).sort()
|
||||
|
||||
for (const path of sortedPaths) {
|
||||
const entity = pathToEntity.get(path)!
|
||||
|
||||
// Apply filter if provided
|
||||
if (options.filter && !options.filter(entity)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip hidden files if requested
|
||||
if (!options.includeHidden && entity.metadata.name.startsWith('.')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create node for this entity
|
||||
const node: TreeNode = {
|
||||
name: entity.metadata.name,
|
||||
path: entity.metadata.path,
|
||||
type: entity.metadata.vfsType,
|
||||
entityId: entity.id,
|
||||
metadata: entity.metadata
|
||||
}
|
||||
|
||||
if (entity.metadata.vfsType === 'directory') {
|
||||
node.children = []
|
||||
}
|
||||
|
||||
pathToNode.set(path, node)
|
||||
|
||||
// Find parent and attach
|
||||
const parentPath = this.getParentPath(path)
|
||||
const parentNode = pathToNode.get(parentPath)
|
||||
|
||||
if (parentNode && parentNode.children) {
|
||||
parentNode.children.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children if requested
|
||||
if (options.sort) {
|
||||
this.sortTreeNodes(rootNode, options.sort)
|
||||
}
|
||||
|
||||
// Apply depth limit if specified
|
||||
if (options.maxDepth !== undefined) {
|
||||
this.limitDepth(rootNode, options.maxDepth)
|
||||
}
|
||||
|
||||
return rootNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct children only - guaranteed no self-inclusion
|
||||
*/
|
||||
static getDirectChildren(
|
||||
entities: VFSEntity[],
|
||||
parentPath: string
|
||||
): VFSEntity[] {
|
||||
const children: VFSEntity[] = []
|
||||
const parentDepth = parentPath === '/' ? 0 : parentPath.split('/').length - 1
|
||||
|
||||
for (const entity of entities) {
|
||||
const path = entity.metadata.path
|
||||
|
||||
// Critical check 1: Skip if this IS the parent
|
||||
if (path === parentPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if entity is a direct child
|
||||
if (path.startsWith(parentPath)) {
|
||||
const relativePath = parentPath === '/'
|
||||
? path.substring(1)
|
||||
: path.substring(parentPath.length + 1)
|
||||
|
||||
// Direct child has no additional slashes
|
||||
if (!relativePath.includes('/')) {
|
||||
children.push(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendants (recursive children)
|
||||
*/
|
||||
static getDescendants(
|
||||
entities: VFSEntity[],
|
||||
ancestorPath: string,
|
||||
includeAncestor: boolean = false
|
||||
): VFSEntity[] {
|
||||
const descendants: VFSEntity[] = []
|
||||
|
||||
for (const entity of entities) {
|
||||
const path = entity.metadata.path
|
||||
|
||||
// Include ancestor only if explicitly requested
|
||||
if (path === ancestorPath) {
|
||||
if (includeAncestor) {
|
||||
descendants.push(entity)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if entity is under ancestor path
|
||||
const prefix = ancestorPath === '/' ? '/' : ancestorPath + '/'
|
||||
if (path.startsWith(prefix)) {
|
||||
descendants.push(entity)
|
||||
}
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a tree structure back to a list
|
||||
*/
|
||||
static flattenTree(node: TreeNode): TreeNode[] {
|
||||
const result: TreeNode[] = [node]
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
result.push(...this.flattenTree(child))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a node in the tree by path
|
||||
*/
|
||||
static findNode(root: TreeNode, targetPath: string): TreeNode | null {
|
||||
if (root.path === targetPath) {
|
||||
return root
|
||||
}
|
||||
|
||||
if (root.children) {
|
||||
for (const child of root.children) {
|
||||
const found = this.findNode(child, targetPath)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tree statistics
|
||||
*/
|
||||
static getTreeStats(node: TreeNode): {
|
||||
totalNodes: number
|
||||
files: number
|
||||
directories: number
|
||||
maxDepth: number
|
||||
totalSize?: number
|
||||
} {
|
||||
let stats = {
|
||||
totalNodes: 0,
|
||||
files: 0,
|
||||
directories: 0,
|
||||
maxDepth: 0,
|
||||
totalSize: 0
|
||||
}
|
||||
|
||||
function traverse(n: TreeNode, depth: number) {
|
||||
stats.totalNodes++
|
||||
stats.maxDepth = Math.max(stats.maxDepth, depth)
|
||||
|
||||
if (n.type === 'file') {
|
||||
stats.files++
|
||||
if (n.metadata?.size) {
|
||||
stats.totalSize += n.metadata.size
|
||||
}
|
||||
} else {
|
||||
stats.directories++
|
||||
}
|
||||
|
||||
if (n.children) {
|
||||
for (const child of n.children) {
|
||||
traverse(child, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(node, 0)
|
||||
return stats
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static getParentPath(path: string): string {
|
||||
if (path === '/') return '/'
|
||||
const parts = path.split('/')
|
||||
parts.pop()
|
||||
return parts.length === 1 ? '/' : parts.join('/')
|
||||
}
|
||||
|
||||
private static sortTreeNodes(node: TreeNode, sortBy: 'name' | 'modified' | 'size'): void {
|
||||
if (!node.children) return
|
||||
|
||||
node.children.sort((a, b) => {
|
||||
// Directories first, then files
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name)
|
||||
case 'modified':
|
||||
const aTime = a.metadata?.modified || 0
|
||||
const bTime = b.metadata?.modified || 0
|
||||
return bTime - aTime
|
||||
case 'size':
|
||||
const aSize = a.metadata?.size || 0
|
||||
const bSize = b.metadata?.size || 0
|
||||
return bSize - aSize
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
// Recursively sort children
|
||||
for (const child of node.children) {
|
||||
this.sortTreeNodes(child, sortBy)
|
||||
}
|
||||
}
|
||||
|
||||
private static limitDepth(node: TreeNode, maxDepth: number, currentDepth: number = 0): void {
|
||||
if (currentDepth >= maxDepth) {
|
||||
delete node.children
|
||||
return
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
this.limitDepth(child, maxDepth, currentDepth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tree structure - ensures no recursion
|
||||
*/
|
||||
static validateTree(node: TreeNode, visited: Set<string> = new Set()): {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check for cycles
|
||||
if (visited.has(node.path)) {
|
||||
errors.push(`Cycle detected at path: ${node.path}`)
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
visited.add(node.path)
|
||||
|
||||
// Check children
|
||||
if (node.children) {
|
||||
const childPaths = new Set<string>()
|
||||
|
||||
for (const child of node.children) {
|
||||
// Check for duplicate children
|
||||
if (childPaths.has(child.path)) {
|
||||
errors.push(`Duplicate child path: ${child.path}`)
|
||||
}
|
||||
childPaths.add(child.path)
|
||||
|
||||
// Check child is not parent
|
||||
if (child.path === node.path) {
|
||||
errors.push(`Directory contains itself: ${node.path}`)
|
||||
}
|
||||
|
||||
// Check child is actually under parent
|
||||
if (node.path !== '/' && !child.path.startsWith(node.path + '/')) {
|
||||
errors.push(`Child ${child.path} not under parent ${node.path}`)
|
||||
}
|
||||
|
||||
// Recursively validate children
|
||||
const childValidation = this.validateTree(child, new Set(visited))
|
||||
errors.push(...childValidation.errors)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -408,6 +408,154 @@ export class VirtualFileSystem implements IVirtualFileSystem {
|
|||
// Knowledge Layer hooks will be added by augmentation if enabled
|
||||
}
|
||||
|
||||
// ============= Tree Operations (NEW) =============
|
||||
|
||||
/**
|
||||
* Get only direct children of a directory - guaranteed no self-inclusion
|
||||
* This is the SAFE way to get children for building tree UIs
|
||||
*/
|
||||
async getDirectChildren(path: string): Promise<VFSEntity[]> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
const entityId = await this.pathResolver.resolve(path)
|
||||
const entity = await this.getEntityById(entityId)
|
||||
|
||||
// Verify it's a directory
|
||||
if (entity.metadata.vfsType !== 'directory') {
|
||||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDirectChildren')
|
||||
}
|
||||
|
||||
// Use the safe getChildren from PathResolver
|
||||
const children = await this.pathResolver.getChildren(entityId)
|
||||
|
||||
// Double-check no self-inclusion (paranoid safety)
|
||||
return children.filter(child => child.metadata.path !== path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a properly structured tree for the given path
|
||||
* This prevents recursion issues common when building file explorers
|
||||
*/
|
||||
async getTreeStructure(path: string, options?: {
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
sort?: 'name' | 'modified' | 'size'
|
||||
}): Promise<any> {
|
||||
await this.ensureInitialized()
|
||||
const { VFSTreeUtils } = await import('./TreeUtils.js')
|
||||
|
||||
const entityId = await this.pathResolver.resolve(path)
|
||||
const entity = await this.getEntityById(entityId)
|
||||
|
||||
if (entity.metadata.vfsType !== 'directory') {
|
||||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getTreeStructure')
|
||||
}
|
||||
|
||||
// Recursively gather all descendants
|
||||
const allEntities: VFSEntity[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
const gatherDescendants = async (dirId: string) => {
|
||||
if (visited.has(dirId)) return // Prevent cycles
|
||||
visited.add(dirId)
|
||||
|
||||
const children = await this.pathResolver.getChildren(dirId)
|
||||
for (const child of children) {
|
||||
allEntities.push(child)
|
||||
if (child.metadata.vfsType === 'directory') {
|
||||
await gatherDescendants(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await gatherDescendants(entityId)
|
||||
|
||||
// Build safe tree structure
|
||||
return VFSTreeUtils.buildTree(allEntities, path, options || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendants of a directory (flat list)
|
||||
*/
|
||||
async getDescendants(path: string, options?: {
|
||||
includeAncestor?: boolean
|
||||
type?: 'file' | 'directory'
|
||||
}): Promise<VFSEntity[]> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
const entityId = await this.pathResolver.resolve(path)
|
||||
const entity = await this.getEntityById(entityId)
|
||||
|
||||
if (entity.metadata.vfsType !== 'directory') {
|
||||
throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'getDescendants')
|
||||
}
|
||||
|
||||
const descendants: VFSEntity[] = []
|
||||
if (options?.includeAncestor) {
|
||||
descendants.push(entity)
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const queue = [entityId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!
|
||||
if (visited.has(currentId)) continue
|
||||
visited.add(currentId)
|
||||
|
||||
const children = await this.pathResolver.getChildren(currentId)
|
||||
for (const child of children) {
|
||||
// Filter by type if specified
|
||||
if (!options?.type || child.metadata.vfsType === options.type) {
|
||||
descendants.push(child)
|
||||
}
|
||||
|
||||
// Add directories to queue for traversal
|
||||
if (child.metadata.vfsType === 'directory') {
|
||||
queue.push(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a path and return structured information
|
||||
* This is the recommended method for file explorers to use
|
||||
*/
|
||||
async inspect(path: string): Promise<{
|
||||
node: VFSEntity
|
||||
children: VFSEntity[]
|
||||
parent: VFSEntity | null
|
||||
stats: VFSStats
|
||||
}> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
const entityId = await this.pathResolver.resolve(path)
|
||||
const entity = await this.getEntityById(entityId)
|
||||
const stats = await this.stat(path)
|
||||
|
||||
let children: VFSEntity[] = []
|
||||
if (entity.metadata.vfsType === 'directory') {
|
||||
children = await this.getDirectChildren(path)
|
||||
}
|
||||
|
||||
let parent: VFSEntity | null = null
|
||||
if (path !== '/') {
|
||||
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
|
||||
const parentId = await this.pathResolver.resolve(parentPath)
|
||||
parent = await this.getEntityById(parentId)
|
||||
}
|
||||
|
||||
return {
|
||||
node: entity,
|
||||
children,
|
||||
parent,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Directory Operations =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -386,6 +386,24 @@ export interface IVirtualFileSystem {
|
|||
rmdir(path: string, options?: { recursive?: boolean }): Promise<void>
|
||||
readdir(path: string, options?: ReaddirOptions): Promise<string[] | VFSDirent[]>
|
||||
|
||||
// Tree operations (NEW - prevents recursion issues)
|
||||
getDirectChildren(path: string): Promise<VFSEntity[]>
|
||||
getTreeStructure(path: string, options?: {
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
sort?: 'name' | 'modified' | 'size'
|
||||
}): Promise<any>
|
||||
getDescendants(path: string, options?: {
|
||||
includeAncestor?: boolean
|
||||
type?: 'file' | 'directory'
|
||||
}): Promise<VFSEntity[]>
|
||||
inspect(path: string): Promise<{
|
||||
node: VFSEntity
|
||||
children: VFSEntity[]
|
||||
parent: VFSEntity | null
|
||||
stats: VFSStats
|
||||
}>
|
||||
|
||||
// Metadata operations
|
||||
stat(path: string): Promise<VFSStats>
|
||||
lstat(path: string): Promise<VFSStats>
|
||||
|
|
|
|||
337
tests/vfs/tree-operations.test.ts
Normal file
337
tests/vfs/tree-operations.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue