feat: add mock OPFS du command for recursive size inspection

This commit is contained in:
Xiaohan-Tian
2026-07-05 13:52:32 -07:00
parent 722ba685eb
commit f9334dc30d
2 changed files with 303 additions and 20 deletions
+197
View File
@@ -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<File> {
return new File([this.content], this.name, { lastModified: this.lastModified });
}
}
class MockFileSystemDirectoryHandle {
public kind = 'directory' as const;
private readonly children = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
constructor(public name: string) {}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
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<MockFileSystemFileHandle> {
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<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
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<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
(KGDebugger as unknown as { _instance: KGDebugger | null })._instance = null;
const children = (mockRoot as unknown as {
children: Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>;
}).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');
});
});
+106 -20
View File
@@ -676,7 +676,7 @@ export class KGDebugger {
*/ */
public async startShell(): Promise<void> { public async startShell(): Promise<void> {
console.log('📟 OPFS Interactive Shell'); console.log('📟 OPFS Interactive Shell');
console.log(' Commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>'); console.log(' Commands: pwd, ls, du [path], cd <path>, cat <file>, dl <file>, rm <name>');
console.log(' Type "exit" or click Cancel to quit.\n'); console.log(' Type "exit" or click Cancel to quit.\n');
let lastOutput = ''; let lastOutput = '';
@@ -731,6 +731,7 @@ export class KGDebugger {
* Supported commands: * Supported commands:
* pwd — print current directory * pwd — print current directory
* ls — list files/folders (like ls -lla) * ls — list files/folders (like ls -lla)
* du [path] — print recursive file/folder sizes in bytes
* cd <path> — change directory (supports .., /, relative, and quoted paths) * cd <path> — change directory (supports .., /, relative, and quoted paths)
* cat <file> — print file contents * cat <file> — print file contents
* dl <file> — download a file to your local machine * dl <file> — download a file to your local machine
@@ -739,6 +740,7 @@ export class KGDebugger {
* Usage in console: * Usage in console:
* await KGDebugger.opfs('pwd') * await KGDebugger.opfs('pwd')
* await KGDebugger.opfs('ls') * await KGDebugger.opfs('ls')
* await KGDebugger.opfs('du')
* await KGDebugger.opfs('cd projects') * await KGDebugger.opfs('cd projects')
* await KGDebugger.opfs('cat project.json') * await KGDebugger.opfs('cat project.json')
*/ */
@@ -758,6 +760,10 @@ export class KGDebugger {
await this.opfsLs(); await this.opfsLs();
break; break;
case 'du':
await this.opfsDu(arg);
break;
case 'cd': case 'cd':
await this.opfsCd(arg); await this.opfsCd(arg);
break; break;
@@ -776,7 +782,7 @@ export class KGDebugger {
default: default:
console.log(`opfs: command not found: ${cmd}`); console.log(`opfs: command not found: ${cmd}`);
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>'); console.log('Available commands: pwd, ls, du [path], cd <path>, cat <file>, dl <file>, rm <name>');
} }
} catch (error) { } catch (error) {
console.error(`opfs: ${error}`); console.error(`opfs: ${error}`);
@@ -840,6 +846,103 @@ export class KGDebugger {
} }
} }
private async opfsDu(path: string): Promise<void> {
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<FileSystemDirectoryHandle | FileSystemFileHandle> {
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<number> {
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<void> { private async opfsCd(path: string): Promise<void> {
if (!path || path === '') { if (!path || path === '') {
// cd with no args goes to root // cd with no args goes to root
@@ -847,29 +950,12 @@ export class KGDebugger {
return; return;
} }
let segments: string[];
if (path === '/') { if (path === '/') {
this.opfsCwd = []; this.opfsCwd = [];
return; 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 = this.opfsResolvePathSegments(path);
const resolved: string[] = [];
for (const seg of segments) {
if (seg === '.') continue;
if (seg === '..') {
resolved.pop();
} else {
resolved.push(seg);
}
}
// Verify the path exists // Verify the path exists
let dir = await navigator.storage.getDirectory(); let dir = await navigator.storage.getDirectory();