Skip to content
Closed
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@
"build": "npm run compile",
"package": "npm run build && npx vsce package",
"package-all": "npm run build && npx vsce package",
"test": "npx tsx --test tests/unit/*.test.ts"
"test": "npx tsx --tsconfig tsconfig.test.json --test tests/unit/*.test.ts"
},
"devDependencies": {
"@types/node": "^25.2.0",
Expand Down
77 changes: 77 additions & 0 deletions tests/mocks/vscode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@

export class Uri {
scheme: string;
authority: string;
path: string;
query: string;
fragment: string;

constructor(scheme: string, authority: string, path: string, query: string, fragment: string) {
this.scheme = scheme;
this.authority = authority;
this.path = path;
this.query = query;
this.fragment = fragment;
}

static parse(str: string) {
// simple parser
// We assume file URI or similar
// For tests, we can just return a dummy
const url = new URL(str);
return new Uri(url.protocol.replace(":", ""), url.host, url.pathname, url.search, url.hash);
}

static file(path: string) {
return new Uri("file", "", path, "", "");
}

with(change: any) {
return new Uri(
change.scheme || this.scheme,
change.authority || this.authority,
change.path || this.path,
change.query || this.query,
change.fragment || this.fragment
);
}

toString() {
return `${this.scheme}://${this.authority}${this.path}`;
}

fsPath() {
return this.path;
}
}

export class FileSystemError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
static FileNotFound(uri?: Uri) { return new FileSystemError("File not found", "FileNotFound"); }
static NoPermissions(message?: string) { return new FileSystemError(message || "No permissions", "NoPermissions"); }
static Unavailable(message?: string) { return new FileSystemError(message || "Unavailable", "Unavailable"); }
}

export class EventEmitter<T> {
listeners: Array<(e: T) => any> = [];
event = (listener: (e: T) => any) => {
this.listeners.push(listener);
return { dispose: () => {} };
}
fire(data: T) { this.listeners.forEach(l => l(data)); }
dispose() { this.listeners = []; }
}

export class Disposable {
callOnDispose: () => void;
constructor(callOnDispose: () => void) { this.callOnDispose = callOnDispose; }
dispose() { if (this.callOnDispose) this.callOnDispose(); }
}

export enum FileType { File = 1, Directory = 2 }
export enum FilePermission { Readonly = 1 }
export enum FileChangeType { Changed = 1, Created = 2, Deleted = 3 }
139 changes: 139 additions & 0 deletions tests/unit/virtualFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import * as vscode from 'vscode';
import { SQLiteFileSystemProvider } from '../../src/virtualFileSystem';
import { DocumentRegistry } from '../../src/documentRegistry';

// Mock Document Interface matching DatabaseDocument behavior used by FileSystemProvider
interface MockDocument {
databaseOperations: {
updateCell: (table: string, rowId: number, column: string, value: any) => Promise<void>;
};
recordExternalModification: (mod: any) => void;
}

describe('SQLiteFileSystemProvider', () => {
let provider: SQLiteFileSystemProvider;
let mockDocument: MockDocument;
const documentKey = 'mock-doc-key';

// Helper to create URI.
// Format: /<document_key>/<table>/<group>/<rowid>/<filename>
const createUri = (path: string) => vscode.Uri.file(path).with({ scheme: 'sqlite-explorer' });

beforeEach(() => {
provider = new SQLiteFileSystemProvider();

mockDocument = {
databaseOperations: {
updateCell: async () => {} // Default mock
},
recordExternalModification: () => {}
};

// Populate Registry
DocumentRegistry.set(documentKey, mockDocument as any);
});

afterEach(() => {
DocumentRegistry.clear();
mock.restoreAll();
});

it('writeFile should update cell with valid text content', async (t) => {
const table = 'users';
const rowId = '1';
const column = 'name';
const filename = `${column}.txt`;
const path = `/${documentKey}/${table}/group/${rowId}/${filename}`;
const uri = createUri(path);

const contentStr = 'Alice';
const content = new TextEncoder().encode(contentStr);

// Mock updateCell to verify arguments
const updateCellMock = t.mock.fn(async (tbl: string, rid: number, col: string, val: any) => {
assert.strictEqual(tbl, table);
assert.strictEqual(rid, 1);
assert.strictEqual(col, column);
assert.strictEqual(val, contentStr);
});
mockDocument.databaseOperations.updateCell = updateCellMock;

// Mock recordExternalModification
const recordMock = t.mock.fn();
mockDocument.recordExternalModification = recordMock;

await provider.writeFile(uri, content, { create: false, overwrite: true });

assert.strictEqual(updateCellMock.mock.callCount(), 1);
assert.strictEqual(recordMock.mock.callCount(), 1);

// Verify modification record
const callArgs = recordMock.mock.calls[0].arguments;
assert.strictEqual(callArgs[0].newValue, contentStr);
assert.strictEqual(callArgs[0].modificationType, 'cell_update');
});

it('writeFile should handle binary content (invalid utf-8) by saving as Uint8Array', async (t) => {
const table = 'data';
const rowId = '2';
const column = 'blob';
const path = `/${documentKey}/${table}/group/${rowId}/${column}.bin`;
const uri = createUri(path);

// Invalid UTF-8 sequence (0xFF cannot appear in valid UTF-8)
const content = new Uint8Array([0xFF, 0xFF]);

const updateCellMock = t.mock.fn(async (tbl: string, rid: number, col: string, val: any) => {
assert.ok(val instanceof Uint8Array);
assert.deepStrictEqual(val, content);
});
mockDocument.databaseOperations.updateCell = updateCellMock;
mockDocument.recordExternalModification = t.mock.fn();

await provider.writeFile(uri, content, { create: false, overwrite: true });

assert.strictEqual(updateCellMock.mock.callCount(), 1);
});

it('writeFile should throw NoPermissions for __create__.sql', async () => {
const path = `/${documentKey}/table/group/__create__.sql/foo.sql`;
const uri = createUri(path);

await assert.rejects(
async () => await provider.writeFile(uri, new Uint8Array(), { create: false, overwrite: true }),
(err: any) => {
assert.strictEqual(err.code, 'NoPermissions');
return true;
}
);
});

it('writeFile should throw Unavailable for invalid Row ID', async () => {
const path = `/${documentKey}/table/group/invalid-id/col.txt`;
const uri = createUri(path);

await assert.rejects(
async () => await provider.writeFile(uri, new Uint8Array(), { create: false, overwrite: true }),
(err: any) => {
assert.strictEqual(err.code, 'Unavailable');
assert.match(err.message, /Invalid Row ID/);
return true;
}
);
});

it('writeFile should throw FileNotFound if document not found', async () => {
const path = `/non-existent-key/table/group/1/col.txt`;
const uri = createUri(path);

await assert.rejects(
async () => await provider.writeFile(uri, new Uint8Array(), { create: false, overwrite: true }),
(err: any) => {
assert.strictEqual(err.code, 'FileNotFound');
return true;
}
);
});
});
9 changes: 9 additions & 0 deletions tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"vscode": ["./tests/mocks/vscode.ts"]
}
}
}