From f9334dc30d724fa2d7abca038acd4125375da7bf Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:52:32 -0700 Subject: [PATCH] feat: add mock OPFS du command for recursive size inspection --- src/core/KGDebugger.test.ts | 197 ++++++++++++++++++++++++++++++++++++ src/core/KGDebugger.ts | 126 +++++++++++++++++++---- 2 files changed, 303 insertions(+), 20 deletions(-) create mode 100644 src/core/KGDebugger.test.ts diff --git a/src/core/KGDebugger.test.ts b/src/core/KGDebugger.test.ts new file mode 100644 index 0000000..b42ba0e --- /dev/null +++ b/src/core/KGDebugger.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./KGCore', () => ({ + KGCore: { + instance: () => ({ + getSelectedItems: () => [], + getCurrentProject: () => ({}), + }), + }, +})); + +vi.mock('./region/KGMidiRegion', () => ({ + KGMidiRegion: class {}, +})); + +vi.mock('../util/abcNotationUtil', () => ({ + convertRegionToABCNotation: vi.fn(), + convertBeatRangeChordProgressionToABCNotation: vi.fn(), +})); + +vi.mock('../util/xmlUtil', () => ({ + extractXMLFromString: vi.fn(), +})); + +vi.mock('../agent/core/AgentCore', () => ({ + AgentCore: class {}, +})); + +vi.mock('../agent/tools', () => ({ + AVAILABLE_TOOLS: {}, +})); + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + activeRegionId: null, + tracks: [], + }), + }, +})); + +import { KGDebugger } from './KGDebugger'; + +class MockFileSystemFileHandle { + public kind = 'file' as const; + + constructor( + public name: string, + private readonly content: string, + private readonly lastModified: number = Date.now(), + ) {} + + async getFile(): Promise { + return new File([this.content], this.name, { lastModified: this.lastModified }); + } +} + +class MockFileSystemDirectoryHandle { + public kind = 'directory' as const; + private readonly children = new Map(); + + constructor(public name: string) {} + + async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { + let child = this.children.get(name); + if (!child || child.kind !== 'directory') { + if (!options?.create) { + throw new DOMException(`Directory "${name}" not found`, 'NotFoundError'); + } + child = new MockFileSystemDirectoryHandle(name); + this.children.set(name, child); + } + return child; + } + + async getFileHandle(name: string, options?: { create?: boolean }): Promise { + let child = this.children.get(name); + if (!child || child.kind !== 'file') { + if (!options?.create) { + throw new DOMException(`File "${name}" not found`, 'NotFoundError'); + } + child = new MockFileSystemFileHandle(name, ''); + this.children.set(name, child); + } + return child; + } + + addDirectory(name: string): MockFileSystemDirectoryHandle { + const dir = new MockFileSystemDirectoryHandle(name); + this.children.set(name, dir); + return dir; + } + + addFile(name: string, content: string): MockFileSystemFileHandle { + const file = new MockFileSystemFileHandle(name, content); + this.children.set(name, file); + return file; + } + + async *values(): AsyncIterableIterator { + for (const child of this.children.values()) { + yield child; + } + } +} + +const mockRoot = new MockFileSystemDirectoryHandle('root'); + +vi.stubGlobal('navigator', { + ...navigator, + storage: { + getDirectory: vi.fn(() => Promise.resolve(mockRoot)), + }, +}); + +describe('KGDebugger OPFS du', () => { + let debuggerInstance: KGDebugger; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + (KGDebugger as unknown as { _instance: KGDebugger | null })._instance = null; + const children = (mockRoot as unknown as { + children: Map; + }).children; + children.clear(); + + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + debuggerInstance = KGDebugger.instance(); + logSpy.mockClear(); + errorSpy.mockClear(); + }); + + it('reports 0 for an empty current directory', async () => { + await debuggerInstance.opfs('du'); + + expect(logSpy).toHaveBeenCalledWith('0 .'); + }); + + it('reports file sizes for direct children in the current directory', async () => { + mockRoot.addFile('beat.txt', '12345'); + mockRoot.addFile('melody.mid', '123456789'); + + await debuggerInstance.opfs('du'); + + expect(logSpy.mock.calls).toEqual([ + ['5 beat.txt'], + ['9 melody.mid'], + ]); + }); + + it('reports recursive directory sizes and appends trailing slashes', async () => { + const projects = mockRoot.addDirectory('projects'); + projects.addFile('meta.json', '{}'); + const media = projects.addDirectory('media'); + media.addFile('take.wav', '1234567'); + + await debuggerInstance.opfs('du'); + + expect(logSpy).toHaveBeenCalledWith('9 projects/'); + }); + + it('supports relative and absolute paths', async () => { + const songs = mockRoot.addDirectory('songs'); + const demos = songs.addDirectory('demos'); + demos.addFile('idea.txt', '1234'); + + await debuggerInstance.opfs('cd songs'); + logSpy.mockClear(); + + await debuggerInstance.opfs('du demos'); + await debuggerInstance.opfs('du /songs/demos'); + + expect(logSpy.mock.calls).toEqual([ + ['4 demos/'], + ['4 demos/'], + ]); + }); + + it('keeps ls output unchanged for directories', async () => { + mockRoot.addDirectory('archive'); + mockRoot.addFile('notes.txt', '1234'); + + await debuggerInstance.opfs('ls'); + + expect(logSpy.mock.calls).toContainEqual(['total 2 (/)']); + expect(logSpy.mock.calls).toContainEqual(['drwxr-xr-x - - archive/']); + expect(logSpy.mock.calls).toContainEqual([expect.stringMatching(/^-rw-r--r--\s+4\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s+notes\.txt$/)]); + }); + + it('reports missing paths with the du-specific error message', async () => { + await debuggerInstance.opfs('du missing'); + + expect(errorSpy).toHaveBeenCalledWith('opfs: du: no such file or directory: missing'); + }); +}); diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts index a08763c..a2cc87a 100644 --- a/src/core/KGDebugger.ts +++ b/src/core/KGDebugger.ts @@ -676,7 +676,7 @@ export class KGDebugger { */ public async startShell(): Promise { console.log('📟 OPFS Interactive Shell'); - console.log(' Commands: pwd, ls, cd , cat , dl , rm '); + console.log(' Commands: pwd, ls, du [path], cd , cat , dl , rm '); console.log(' Type "exit" or click Cancel to quit.\n'); let lastOutput = ''; @@ -731,6 +731,7 @@ export class KGDebugger { * Supported commands: * pwd — print current directory * ls — list files/folders (like ls -lla) + * du [path] — print recursive file/folder sizes in bytes * cd — change directory (supports .., /, relative, and quoted paths) * cat — print file contents * dl — download a file to your local machine @@ -739,6 +740,7 @@ export class KGDebugger { * Usage in console: * await KGDebugger.opfs('pwd') * await KGDebugger.opfs('ls') + * await KGDebugger.opfs('du') * await KGDebugger.opfs('cd projects') * await KGDebugger.opfs('cat project.json') */ @@ -758,6 +760,10 @@ export class KGDebugger { await this.opfsLs(); break; + case 'du': + await this.opfsDu(arg); + break; + case 'cd': await this.opfsCd(arg); break; @@ -776,7 +782,7 @@ export class KGDebugger { default: console.log(`opfs: command not found: ${cmd}`); - console.log('Available commands: pwd, ls, cd , cat , dl , rm '); + console.log('Available commands: pwd, ls, du [path], cd , cat , dl , rm '); } } catch (error) { console.error(`opfs: ${error}`); @@ -840,6 +846,103 @@ export class KGDebugger { } } + private async opfsDu(path: string): Promise { + if (!path) { + const dir = await this.opfsResolveCwd(); + const entries: Array<{ name: string; size: number }> = []; + + for await (const entry of dir.values()) { + entries.push({ + name: entry.kind === 'directory' ? `${entry.name}/` : entry.name, + size: await this.opfsGetEntrySize(entry), + }); + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + + if (entries.length === 0) { + console.log('0 .'); + return; + } + + for (const entry of entries) { + console.log(`${entry.size} ${entry.name}`); + } + return; + } + + try { + const resolved = await this.opfsResolvePath(path); + const displayName = resolved.kind === 'directory' + ? `${resolved.name}/` + : resolved.name; + const size = await this.opfsGetEntrySize(resolved); + console.log(`${size} ${displayName}`); + } catch { + console.error(`opfs: du: no such file or directory: ${path}`); + } + } + + private async opfsResolvePath(path: string): Promise { + const { parentDir, name } = await this.opfsResolveParentAndName(path); + + try { + return await parentDir.getDirectoryHandle(name); + } catch { + return parentDir.getFileHandle(name); + } + } + + private async opfsResolveParentAndName(path: string): Promise<{ parentDir: FileSystemDirectoryHandle; name: string }> { + if (!path || path === '') { + throw new Error('missing path'); + } + + const segments = this.opfsResolvePathSegments(path); + const name = segments.pop(); + + if (!name) { + throw new Error('missing path'); + } + + let parentDir = await navigator.storage.getDirectory(); + for (const segment of segments) { + parentDir = await parentDir.getDirectoryHandle(segment); + } + + return { parentDir, name }; + } + + private opfsResolvePathSegments(path: string): string[] { + const rawSegments = path.startsWith('/') + ? path.split('/').filter(Boolean) + : [...this.opfsCwd, ...path.split('/').filter(Boolean)]; + + const resolved: string[] = []; + for (const segment of rawSegments) { + if (segment === '.') continue; + if (segment === '..') { + resolved.pop(); + } else { + resolved.push(segment); + } + } + + return resolved; + } + + private async opfsGetEntrySize(entry: FileSystemDirectoryHandle | FileSystemFileHandle): Promise { + if (entry.kind === 'file') { + return (await entry.getFile()).size; + } + + let total = 0; + for await (const child of entry.values()) { + total += await this.opfsGetEntrySize(child); + } + return total; + } + private async opfsCd(path: string): Promise { if (!path || path === '') { // cd with no args goes to root @@ -847,29 +950,12 @@ export class KGDebugger { return; } - let segments: string[]; - if (path === '/') { this.opfsCwd = []; return; - } else if (path.startsWith('/')) { - // Absolute path - segments = path.split('/').filter(Boolean); - } else { - // Relative path - segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]; } - // Resolve . and .. - const resolved: string[] = []; - for (const seg of segments) { - if (seg === '.') continue; - if (seg === '..') { - resolved.pop(); - } else { - resolved.push(seg); - } - } + const resolved = this.opfsResolvePathSegments(path); // Verify the path exists let dir = await navigator.storage.getDirectory();