feat: add mock OPFS du command for recursive size inspection
This commit is contained in:
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user