Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[Storage] Fix fileExists() to return true for directories
The fileExists() method in InMemoryFilesystemBackend and OpfsFilesystemBackend
was incorrectly returning false for directories, only checking for files.
This caused the Blueprint Editor to crash when loading blueprints with
bundled resources at the root level (e.g., ./piel.zip), because the code
tried to mkdir('/') after fileExists('/') returned false.

This change makes fileExists() behave as a general "path exists" check,
returning true for both files and directories. The root directory now
correctly returns true as well.

Also adds comprehensive unit tests for InMemoryFilesystemBackend to
document and verify the expected filesystem semantics.
  • Loading branch information
adamziel committed Nov 30, 2025
commit b86f265a20f6260eb5846b74bbac49469a8e5fd2
189 changes: 189 additions & 0 deletions packages/playground/storage/src/lib/filesystems.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Filesystem } from './filesystems';
import {
InMemoryFilesystem,
InMemoryFilesystemBackend,
ZipFilesystem,
OverlayFilesystem,
FetchFilesystem,
Expand Down Expand Up @@ -440,3 +441,191 @@ describe('NodeJsFilesystem', () => {
);
});
});

describe('InMemoryFilesystemBackend', () => {
let backend: InMemoryFilesystemBackend;

beforeEach(() => {
backend = new InMemoryFilesystemBackend();
});

describe('fileExists', () => {
it('should return true for root directory', async () => {
expect(await backend.fileExists('/')).toBe(true);
});

it('should return true for existing files', async () => {
await backend.writeFile('/test.txt', new Uint8Array([1, 2, 3]));
expect(await backend.fileExists('/test.txt')).toBe(true);
});

it('should return true for existing directories', async () => {
await backend.mkdir('/mydir');
expect(await backend.fileExists('/mydir')).toBe(true);
});

it('should return false for non-existent paths', async () => {
expect(await backend.fileExists('/nonexistent')).toBe(false);
});

it('should return true for nested directories', async () => {
await backend.mkdir('/parent/child');
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll adjust it in a follow up PR to require an explicit recursive option

expect(await backend.fileExists('/parent')).toBe(true);
expect(await backend.fileExists('/parent/child')).toBe(true);
});
});

describe('isDir', () => {
it('should return true for root directory', async () => {
expect(await backend.isDir('/')).toBe(true);
});

it('should return true for directories', async () => {
await backend.mkdir('/mydir');
expect(await backend.isDir('/mydir')).toBe(true);
});

it('should return false for files', async () => {
await backend.writeFile('/test.txt', new Uint8Array([1, 2, 3]));
expect(await backend.isDir('/test.txt')).toBe(false);
});

it('should return false for non-existent paths', async () => {
expect(await backend.isDir('/nonexistent')).toBe(false);
});
});

describe('mkdir', () => {
it('should create a directory', async () => {
await backend.mkdir('/newdir');
expect(await backend.isDir('/newdir')).toBe(true);
});

it('should create nested directories', async () => {
await backend.mkdir('/parent/child/grandchild');
expect(await backend.isDir('/parent')).toBe(true);
expect(await backend.isDir('/parent/child')).toBe(true);
expect(await backend.isDir('/parent/child/grandchild')).toBe(true);
});

it('should not fail when directory already exists', async () => {
await backend.mkdir('/mydir');
await backend.mkdir('/mydir');
expect(await backend.isDir('/mydir')).toBe(true);
});
});

describe('writeFile and read', () => {
it('should write and read a file', async () => {
const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
await backend.writeFile('/test.txt', data);

const file = await backend.read('/test.txt');
const content = new Uint8Array(await file.arrayBuffer());
expect(content).toEqual(data);
});

it('should create parent directories when writing a file', async () => {
const data = new Uint8Array([1, 2, 3]);
await backend.writeFile('/deep/nested/file.txt', data);

expect(await backend.isDir('/deep')).toBe(true);
expect(await backend.isDir('/deep/nested')).toBe(true);
expect(await backend.fileExists('/deep/nested/file.txt')).toBe(
true
);
});
});

describe('listFiles', () => {
it('should list files in root directory', async () => {
await backend.writeFile('/file1.txt', new Uint8Array([1]));
await backend.writeFile('/file2.txt', new Uint8Array([2]));
await backend.mkdir('/dir1');

const files = await backend.listFiles('/');
expect(files).toContain('file1.txt');
expect(files).toContain('file2.txt');
expect(files).toContain('dir1');
});

it('should list files in subdirectory', async () => {
await backend.mkdir('/mydir');
await backend.writeFile('/mydir/nested.txt', new Uint8Array([1]));

const files = await backend.listFiles('/mydir');
expect(files).toContain('nested.txt');
});
});

describe('unlink', () => {
it('should delete a file', async () => {
await backend.writeFile('/test.txt', new Uint8Array([1]));
expect(await backend.fileExists('/test.txt')).toBe(true);

await backend.unlink('/test.txt');
expect(await backend.fileExists('/test.txt')).toBe(false);
});
});

describe('rmdir', () => {
it('should delete an empty directory', async () => {
await backend.mkdir('/emptydir');
await backend.rmdir('/emptydir', false);
expect(await backend.fileExists('/emptydir')).toBe(false);
});

it('should delete a directory recursively', async () => {
await backend.mkdir('/parent/child');
await backend.writeFile(
'/parent/child/file.txt',
new Uint8Array([1])
);

await backend.rmdir('/parent', true);
expect(await backend.fileExists('/parent')).toBe(false);
});

it('should throw when deleting non-empty directory without recursive flag', async () => {
await backend.mkdir('/parent');
await backend.writeFile('/parent/file.txt', new Uint8Array([1]));

await expect(backend.rmdir('/parent', false)).rejects.toThrow(
'Directory not empty'
);
});
});

describe('mv', () => {
it('should move a file', async () => {
await backend.writeFile('/source.txt', new Uint8Array([1, 2, 3]));
await backend.mv('/source.txt', '/dest.txt');

expect(await backend.fileExists('/source.txt')).toBe(false);
expect(await backend.fileExists('/dest.txt')).toBe(true);
});

it('should move a directory', async () => {
await backend.mkdir('/srcdir');
await backend.writeFile('/srcdir/file.txt', new Uint8Array([1]));
await backend.mv('/srcdir', '/dstdir');

expect(await backend.fileExists('/srcdir')).toBe(false);
expect(await backend.fileExists('/dstdir')).toBe(true);
expect(await backend.fileExists('/dstdir/file.txt')).toBe(true);
});
});

describe('clear', () => {
it('should remove all files and directories', async () => {
await backend.mkdir('/dir1');
await backend.writeFile('/file1.txt', new Uint8Array([1]));
await backend.writeFile('/dir1/nested.txt', new Uint8Array([2]));

await backend.clear();

const files = await backend.listFiles('/');
expect(files).toHaveLength(0);
});
});
});
19 changes: 13 additions & 6 deletions packages/playground/storage/src/lib/filesystems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,17 +552,24 @@ export class OpfsFilesystemBackend implements WritableFilesystemBackend {

async fileExists(absolutePath: string): Promise<boolean> {
const segments = absolutePath.split('/').filter(Boolean);
const fileName = segments.pop();
if (!fileName) {
return false;
// Root always exists
if (segments.length === 0) {
return true;
}
const name = segments.pop()!;
try {
let dir = this.opfsRoot;
for (const segment of segments) {
dir = await dir.getDirectoryHandle(segment);
}
await dir.getFileHandle(fileName);
return true;
// Check if it's a file or directory
try {
await dir.getFileHandle(name);
return true;
} catch {
await dir.getDirectoryHandle(name);
return true;
}
} catch {
return false;
}
Expand Down Expand Up @@ -722,7 +729,7 @@ export class InMemoryFilesystemBackend implements WritableFilesystemBackend {

async fileExists(absolutePath: string): Promise<boolean> {
const node = this.getNode(absolutePath);
return !!node && node.type === 'file';
return !!node;
}

async listFiles(absolutePath: string): Promise<string[]> {
Expand Down