Repository: microsoft/vscode (TypeScript)
Date: 2020-10-23 23:25:39+00:00
Task type: Testing

Issue:
IndexDB file system provider needs a good test suite
Our IndexDB based file system provider is very important for web but currently seems to  lack a good test suite. I suggest to look at `src/vs/platform/files/test/electron-browser/diskFileService.test.ts` and see if some of the tests can run against IndexDB to make sure we have good coverage.


Issue discussion:
Looked into this a bit, as far as I can tell we don't have any serverless tests at all yet, is this correct?
@JacksonKearl not sure I understand, we run our unit tests in both [electron](https://github.com/microsoft/vscode/blob/87847325a66f098621f6650a16d665c9cd07f15a/test/unit/electron/index.js#L6-L6) and [browsers](https://github.com/microsoft/vscode/blob/41b661eb3d135b14d7f66d6342b64ca3bcdee36d/test/unit/browser/index.js#L1-L1), so you should get good coverage if you simply write unit tests. I do not think you need any server for these tests.
Oh I see. I was looking at the API tests, which I think only run on serverfull browser and desktop.

Solution patch:
diff --git a/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts b/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts
--- a/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts
+++ b/src/vs/platform/files/browser/indexedDBFileSystemProvider.ts
@@ -8,14 +8,24 @@ import { IFileSystemProviderWithFileReadWriteCapability, FileSystemProviderCapab
 import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
 import { Event, Emitter } from 'vs/base/common/event';
 import { VSBuffer } from 'vs/base/common/buffer';
-import { joinPath, extUri, dirname } from 'vs/base/common/resources';
+import { Throttler } from 'vs/base/common/async';
 import { localize } from 'vs/nls';
 import * as browser from 'vs/base/browser/browser';
+import { joinPath } from 'vs/base/common/resources';
 
 const INDEXEDDB_VSCODE_DB = 'vscode-web-db';
 export const INDEXEDDB_USERDATA_OBJECT_STORE = 'vscode-userdata-store';
 export const INDEXEDDB_LOGS_OBJECT_STORE = 'vscode-logs-store';
 
+// Standard FS Errors (expected to be thrown in production when invalid FS operations are requested)
+const ERR_FILE_NOT_FOUND = createFileSystemProviderError(localize('fileNotExists', "File does not exist"), FileSystemProviderErrorCode.FileNotFound);
+const ERR_FILE_IS_DIR = createFileSystemProviderError(localize('fileIsDirectory', "File is Directory"), FileSystemProviderErrorCode.FileIsADirectory);
+const ERR_FILE_NOT_DIR = createFileSystemProviderError(localize('fileNotDirectory', "File is not a directory"), FileSystemProviderErrorCode.FileNotADirectory);
+const ERR_DIR_NOT_EMPTY = createFileSystemProviderError(localize('dirIsNotEmpty', "Directory is not empty"), FileSystemProviderErrorCode.Unknown);
+
+// Arbitrary Internal Errors (should never be thrown in production)
+const ERR_UNKNOWN_INTERNAL = (message: string) => createFileSystemProviderError(localize('internal', "Internal error occured in IndexedDB File System Provider. ({0})", message), FileSystemProviderErrorCode.Unknown);
+
 export class IndexedDB {
 
 	private indexedDBPromise: Promise<IDBDatabase | null>;
@@ -65,13 +75,140 @@ export class IndexedDB {
 			};
 		});
 	}
-
 }
 
 export interface IIndexedDBFileSystemProvider extends Disposable, IFileSystemProviderWithFileReadWriteCapability {
 	reset(): Promise<void>;
 }
 
+type DirEntry = [string, FileType];
+
+type IndexedDBFileSystemEntry =
+	| {
+		path: string,
+		type: FileType.Directory,
+		children: Map<string, IndexedDBFileSystemNode>,
+	}
+	| {
+		path: string,
+		type: FileType.File,
+		size: number | undefined,
+	};
+
+class IndexedDBFileSystemNode {
+	public type: FileType;
+
+	constructor(private entry: IndexedDBFileSystemEntry) {
+		this.type = entry.type;
+	}
+
+
+	read(path: string) {
+		return this.doRead(path.split('/').filter(p => p.length));
+	}
+
+	private doRead(pathParts: string[]): IndexedDBFileSystemEntry | undefined {
+		if (pathParts.length === 0) { return this.entry; }
+		if (this.entry.type !== FileType.Directory) {
+			throw ERR_UNKNOWN_INTERNAL('Internal error reading from IndexedDBFSNode -- expected directory at ' + this.entry.path);
+		}
+		const next = this.entry.children.get(pathParts[0]);
+
+		if (!next) { return undefined; }
+		return next.doRead(pathParts.slice(1));
+	}
+
+	delete(path: string) {
+		const toDelete = path.split('/').filter(p => p.length);
+		if (toDelete.length === 0) {
+			if (this.entry.type !== FileType.Directory) {
+				throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode. Expected root entry to be directory`);
+			}
+			this.entry.children.clear();
+		} else {
+			return this.doDelete(toDelete, path);
+		}
+	}
+
+	private doDelete = (pathParts: string[], originalPath: string) => {
+		if (pathParts.length === 0) {
+			throw ERR_UNKNOWN_INTERNAL(`Internal error deleting from IndexedDBFSNode -- got no deletion path parts (encountered while deleting ${originalPath})`);
+		}
+		else if (this.entry.type !== FileType.Directory) {
+			throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected directory at ' + this.entry.path);
+		}
+		else if (pathParts.length === 1) {
+			this.entry.children.delete(pathParts[0]);
+		}
+		else {
+			const next = this.entry.children.get(pathParts[0]);
+			if (!next) {
+				throw ERR_UNKNOWN_INTERNAL('Internal error deleting from IndexedDBFSNode -- expected entry at ' + this.entry.path + '/' + next);
+			}
+			next.doDelete(pathParts.slice(1), originalPath);
+		}
+	};
+
+	add(path: string, entry: { type: 'file', size?: number } | { type: 'dir' }) {
+		this.doAdd(path.split('/').filter(p => p.length), entry, path);
+	}
+
+	private doAdd(pathParts: string[], entry: { type: 'file', size?: number } | { type: 'dir' }, originalPath: string) {
+		if (pathParts.length === 0) {
+			throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- adding empty path (encountered while adding ${originalPath})`);
+		}
+		else if (this.entry.type !== FileType.Directory) {
+			throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- parent is not a directory (encountered while adding ${originalPath})`);
+		}
+		else if (pathParts.length === 1) {
+			const next = pathParts[0];
+			const existing = this.entry.children.get(next);
+			if (entry.type === 'dir') {
+				if (existing?.entry.type === FileType.File) {
+					throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
+				}
+				this.entry.children.set(next, existing ?? new IndexedDBFileSystemNode({
+					type: FileType.Directory,
+					path: this.entry.path + '/' + next,
+					children: new Map(),
+				}));
+			} else {
+				if (existing?.entry.type === FileType.Directory) {
+					throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting directory with file: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
+				}
+				this.entry.children.set(next, new IndexedDBFileSystemNode({
+					type: FileType.File,
+					path: this.entry.path + '/' + next,
+					size: entry.size,
+				}));
+			}
+		}
+		else if (pathParts.length > 1) {
+			const next = pathParts[0];
+			let childNode = this.entry.children.get(next);
+			if (!childNode) {
+				childNode = new IndexedDBFileSystemNode({
+					children: new Map(),
+					path: this.entry.path + '/' + next,
+					type: FileType.Directory
+				});
+				this.entry.children.set(next, childNode);
+			}
+			else if (childNode.type === FileType.File) {
+				throw ERR_UNKNOWN_INTERNAL(`Internal error creating IndexedDBFSNode -- overwriting file entry with directory: ${this.entry.path}/${next} (encountered while adding ${originalPath})`);
+			}
+			childNode.doAdd(pathParts.slice(1), entry, originalPath);
+		}
+	}
+
+	print(indentation = '') {
+		console.log(indentation + this.entry.path);
+		if (this.entry.type === FileType.Directory) {
+			this.entry.children.forEach(child => child.print(indentation + ' '));
+		}
+	}
+}
+
 class IndexedDBFileSystemProvider extends Disposable implements IIndexedDBFileSystemProvider {
 
 	readonly capabilities: FileSystemProviderCapabilities =
@@ -83,11 +220,14 @@ class IndexedDBFileSystemProvider extends Disposable implements IIndexedDBFileSy
 	readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event;
 
 	private readonly versions: Map<string, number> = new Map<string, number>();
-	private readonly dirs: Set<string> = new Set<string>();
 
-	constructor(private readonly scheme: string, private readonly database: IDBDatabase, private readonly store: string) {
+	private cachedFiletree: Promise<IndexedDBFileSystemNode> | undefined;
+	private writeManyThrottler: Throttler;
+
+	constructor(scheme: string, private readonly database: IDBDatabase, private readonly store: string) {
 		super();
-		this.dirs.add('/');
+		this.writeManyThrottler = new Throttler();
+
 	}
 
 	watch(resource: URI, opts: IWatchOptions): IDisposable {
@@ -98,29 +238,22 @@ class IndexedDBFileSystemProvider extends Disposable implements IIndexedDBFileSy
 		try {
 			const resourceStat = await this.stat(resource);
 			if (resourceStat.type === FileType.File) {
-				throw createFileSystemProviderError(localize('fileNotDirectory', "File is not a directory"), FileSystemProviderErrorCode.FileNotADirectory);
+				throw ERR_FILE_NOT_DIR;
 			}
 		} catch (error) { /* Ignore */ }
-
-		// Make sure parent dir exists
-		await this.stat(dirname(resource));
-
-		this.dirs.add(resource.path);
+		(await this.getFiletree()).add(resource.path, { type: 'dir' });
 	}
 
 	async stat(resource: URI): Promise<IStat> {
-		try {
-			const content = await this.readFile(resource);
+		const content = (await this.getFiletree()).read(resource.path);
+		if (content?.type === FileType.File) {
 			return {
 				type: FileType.File,
 				ctime: 0,
 				mtime: this.versions.get(resource.toString()) || 0,
-				size: content.byteLength
+				size: content.size ?? (await this.readFile(resource)).byteLength
 			};
-		} catch (e) {
-		}
-		const files = await this.readdir(resource);
-		if (files.length) {
+		} else if (content?.type === FileType.Directory) {
 			return {
 				type: FileType.Directory,
 				ctime: 0,
@@ -128,134 +261,170 @@ class IndexedDBFileSystemProvider extends Disposable implements IIndexedDBFileSy
 				size: 0
 			};
 		}
-		if (this.dirs.has(resource.path)) {
-			return {
-				type: FileType.Directory,
-				ctime: 0,
-				mtime: 0,
-				size: 0
-			};
+		else {
+			throw ERR_FILE_NOT_FOUND;
 		}
-		throw createFileSystemProviderError(localize('fileNotExists', "File does not exist"), FileSystemProviderErrorCode.FileNotFound);
 	}
 
-	async readdir(resource: URI): Promise<[string, FileType][]> {
-		const hasKey = await this.hasKey(resource.path);
-		if (hasKey) {
-			throw createFileSystemProviderError(localize('fileNotDirectory', "File is not a directory"), FileSystemProviderErrorCode.FileNotADirectory);
+	async readdir(resource: URI): Promise<DirEntry[]> {
+		const entry = (await this.getFiletree()).read(resource.path);
+		if (!entry) {
+			// Dirs aren't saved to disk, so empty dirs will be lost on reload.
+			// Thus we have two options for what happens when you try to read a dir and nothing is found:
+			// - Throw FileSystemProviderErrorCode.FileNotFound
+			// - Return []
+			// We choose to return [] as creating a dir then reading it (even after reload) should not throw an error.
+			return [];
 		}
-		const keys = await this.getAllKeys();
-		const files: Map<string, [string, FileType]> = new Map<string, [string, FileType]>();
-		for (const key of keys) {
-			const keyResource = this.toResource(key);
-			if (extUri.isEqualOrParent(keyResource, resource)) {
-				const path = extUri.relativePath(resource, keyResource);
-				if (path) {
-					const keySegments = path.split('/');
-					files.set(keySegments[0], [keySegments[0], keySegments.length === 1 ? FileType.File : FileType.Directory]);
-				}
-			}
+		if (entry.type !== FileType.Directory) {
+			throw ERR_FILE_NOT_DIR;
+		}
+		else {
+			return [...entry.children.entries()].map(([name, node]) => [name, node.type]);
 		}
-		return [...files.values()];
 	}
 
 	async readFile(resource: URI): Promise<Uint8Array> {
-		const hasKey = await this.hasKey(resource.path);
-		if (!hasKey) {
-			throw createFileSystemProviderError(localize('fileNotFound', "File not found"), FileSystemProviderErrorCode.FileNotFound);
-		}
-		const value = await this.getValue(resource.path);
-		if (typeof value === 'string') {
-			return VSBuffer.fromString(value).buffer;
-		} else {
-			return value;
-		}
+		const buffer = await new Promise<Uint8Array>((c, e) => {
+			const transaction = this.database.transaction([this.store]);
+			const objectStore = transaction.objectStore(this.store);
+			const request = objectStore.get(resource.path);
+			request.onerror = () => e(request.error);
+			request.onsuccess = () => {
+				if (request.result instanceof Uint8Array) {
+					c(request.result);
+				} else if (typeof request.result === 'string') {
+					c(VSBuffer.fromString(request.result).buffer);
+				}
+				else {
+					if (request.result === undefined) {
+						e(ERR_FILE_NOT_FOUND);
+					} else {
+						e(ERR_UNKNOWN_INTERNAL(`IndexedDB entry at "${resource.path}" in unexpected format`));
+					}
+				}
+			};
+		});
+
+		(await this.getFiletree()).add(resource.path, { type: 'file', size: buffer.byteLength });
+		return buffer;
 	}
 
 	async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
-		const hasKey = await this.hasKey(resource.path);
-		if (!hasKey) {
-			const files = await this.readdir(resource);
-			if (files.length) {
-				throw createFileSystemProviderError(localize('fileIsDirectory', "File is Directory"), FileSystemProviderErrorCode.FileIsADirectory);
-			}
+		const existing = await this.stat(resource).catch(() => undefined);
+		if (existing?.type === FileType.Directory) {
+			throw ERR_FILE_IS_DIR;
 		}
-		await this.setValue(resource.path, content);
+
+		this.fileWriteBatch.push({ content, resource });
+		await this.writeManyThrottler.queue(() => this.writeMany());
+		(await this.getFiletree()).add(resource.path, { type: 'file', size: content.byteLength });
 		this.versions.set(resource.toString(), (this.versions.get(resource.toString()) || 0) + 1);
 		this._onDidChangeFile.fire([{ resource, type: FileChangeType.UPDATED }]);
 	}
 
 	async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
-		const hasKey = await this.hasKey(resource.path);
-		if (hasKey) {
-			await this.deleteKey(resource.path);
-			this.versions.delete(resource.path);
-			this._onDidChangeFile.fire([{ resource, type: FileChangeType.DELETED }]);
-			return;
+		let stat: IStat;
+		try {
+			stat = await this.stat(resource);
+		} catch (e) {
+			if (e.code === FileSystemProviderErrorCode.FileNotFound) {
+				return;
+			}
+			throw e;
 		}
 
+		let toDelete: string[];
 		if (opts.recursive) {
-			const files = await this.readdir(resource);
-			await Promise.all(files.map(([key]) => this.delete(joinPath(resource, key), opts)));
+			const tree = (await this.tree(resource));
+			toDelete = tree.map(([path]) => path);
+		} else {
+			if (stat.type === FileType.Directory && (await this.readdir(resource)).length) {
+				throw ERR_DIR_NOT_EMPTY;
+			}
+			toDelete = [resource.path];
 		}
+		await this.deleteKeys(toDelete);
+		(await this.getFiletree()).delete(resource.path);
+		toDelete.forEach(key => this.versions.delete(key));
+		this._onDidChangeFile.fire(toDelete.map(path => ({ resource: resource.with({ path }), type: FileChangeType.DELETED })));
 	}
 
-	rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
-		return Promise.reject(new Error('Not Supported'));
-	}
-
-	private toResource(key: string): URI {
-		return URI.file(key).with({ scheme: this.scheme });
+	private async tree(resource: URI): Promise<DirEntry[]> {
+		if ((await this.stat(resource)).type === FileType.Directory) {
+			const topLevelEntries = (await this.readdir(resource)).map(([key, type]) => {
+				return [joinPath(resource, key).path, type] as [string, FileType];
+			});
+			let allEntries = topLevelEntries;
+			await Promise.all(topLevelEntries.map(
+				async ([key, type]) => {
+					if (type === FileType.Directory) {
+						const childEntries = (await this.tree(resource.with({ path: key })));
+						allEntries = allEntries.concat(childEntries);
+					}
+				}));
+			return allEntries;
+		} else {
+			const entries: DirEntry[] = [[resource.path, FileType.File]];
+			return entries;
+		}
 	}
 
-	async getAllKeys(): Promise<string[]> {
-		return new Promise(async (c, e) => {
-			const transaction = this.database.transaction([this.store]);
-			const objectStore = transaction.objectStore(this.store);
-			const request = objectStore.getAllKeys();
-			request.onerror = () => e(request.error);
-			request.onsuccess = () => c(<string[]>request.result);
-		});
+	rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
+		return Promise.reject(new Error('Not Supported'));
 	}
 
-	hasKey(key: string): Promise<boolean> {
-		return new Promise<boolean>(async (c, e) => {
-			const transaction = this.database.transaction([this.store]);
-			const objectStore = transaction.objectStore(this.store);
-			const request = objectStore.getKey(key);
-			request.onerror = () => e(request.error);
-			request.onsuccess = () => {
-				c(!!request.result);
-			};
-		});
+	private getFiletree(): Promise<IndexedDBFileSystemNode> {
+		if (!this.cachedFiletree) {
+			this.cachedFiletree = new Promise((c, e) => {
+				const transaction = this.database.transaction([this.store]);
+				const objectStore = transaction.objectStore(this.store);
+				const request = objectStore.getAllKeys();
+				request.onerror = () => e(request.error);
+				request.onsuccess = () => {
+					const rootNode = new IndexedDBFileSystemNode({
+						children: new Map(),
+						path: '',
+						type: FileType.Directory
+					});
+					const keys = request.result.map(key => key.toString());
+					keys.forEach(key => rootNode.add(key, { type: 'file' }));
+					c(rootNode);
+				};
+			});
+		}
+		return this.cachedFiletree;
 	}
 
-	getValue(key: string): Promise<Uint8Array | string> {
-		return new Promise(async (c, e) => {
-			const transaction = this.database.transaction([this.store]);
-			const objectStore = transaction.objectStore(this.store);
-			const request = objectStore.get(key);
-			request.onerror = () => e(request.error);
-			request.onsuccess = () => c(request.result || '');
-		});
-	}
+	private fileWriteBatch: { resource: URI, content: Uint8Array }[] = [];
+	private async writeMany() {
+		return new Promise<void>((c, e) => {
+			const fileBatch = this.fileWriteBatch;
+			this.fileWriteBatch = [];
+			if (fileBatch.length === 0) { return c(); }
 
-	setValue(key: string, value: Uint8Array): Promise<void> {
-		return new Promise(async (c, e) => {
 			const transaction = this.database.transaction([this.store], 'readwrite');
+			transaction.onerror = () => e(transaction.error);
 			const objectStore = transaction.objectStore(this.store);
-			const request = objectStore.put(value, key);
-			request.onerror = () => e(request.error);
+			let request: IDBRequest = undefined!;
+			for (const entry of fileBatch) {
+				request = objectStore.put(entry.content, entry.resource.path);
+			}
 			request.onsuccess = () => c();
 		});
 	}
 
-	deleteKey(key: string): Promise<void> {
+	private deleteKeys(keys: string[]): Promise<void> {
 		return new Promise(async (c, e) => {
+			if (keys.length === 0) { return c(); }
 			const transaction = this.database.transaction([this.store], 'readwrite');
+			transaction.onerror = () => e(transaction.error);
 			const objectStore = transaction.objectStore(this.store);
-			const request = objectStore.delete(key);
-			request.onerror = () => e(request.error);
+			let request: IDBRequest = undefined!;
+			for (const key of keys) {
+				request = objectStore.delete(key);
+			}
+
 			request.onsuccess = () => c();
 		});
 	}


Test patch:
diff --git a/src/vs/platform/files/test/browser/indexedDBFileService.test.ts b/src/vs/platform/files/test/browser/indexedDBFileService.test.ts
--- a/src/vs/platform/files/test/browser/indexedDBFileService.test.ts
+++ b/src/vs/platform/files/test/browser/indexedDBFileService.test.ts
@@ -6,17 +6,14 @@
 import * as assert from 'assert';
 import { FileService } from 'vs/platform/files/common/fileService';
 import { Schemas } from 'vs/base/common/network';
-import { posix } from 'vs/base/common/path';
 import { URI } from 'vs/base/common/uri';
-import { FileOperation, FileOperationEvent } from 'vs/platform/files/common/files';
+import { FileOperation, FileOperationError, FileOperationEvent, FileOperationResult, FileSystemProviderErrorCode, FileType } from 'vs/platform/files/common/files';
 import { NullLogService } from 'vs/platform/log/common/log';
 import { DisposableStore } from 'vs/base/common/lifecycle';
 import { IIndexedDBFileSystemProvider, IndexedDB, INDEXEDDB_LOGS_OBJECT_STORE, INDEXEDDB_USERDATA_OBJECT_STORE } from 'vs/platform/files/browser/indexedDBFileSystemProvider';
 import { assertIsDefined } from 'vs/base/common/types';
-
-// FileService doesn't work with \ leading a path. Windows join swaps /'s for \'s,
-// making /-style absolute paths fail isAbsolute checks.
-const join = posix.join;
+import { basename, joinPath } from 'vs/base/common/resources';
+import { bufferToReadable, bufferToStream, VSBuffer, VSBufferReadable, VSBufferReadableStream } from 'vs/base/common/buffer';
 
 suite('IndexedDB File Service', function () {
 
@@ -27,12 +24,43 @@ suite('IndexedDB File Service', function () {
 	let userdataFileProvider: IIndexedDBFileSystemProvider;
 	const testDir = '/';
 
-	const makeLogfileURI = (path: string) => URI.from({ scheme: logSchema, path });
-	const makeUserdataURI = (path: string) => URI.from({ scheme: Schemas.userData, path });
+	const logfileURIFromPaths = (paths: string[]) => joinPath(URI.from({ scheme: logSchema, path: testDir }), ...paths);
+	const userdataURIFromPaths = (paths: readonly string[]) => joinPath(URI.from({ scheme: Schemas.userData, path: testDir }), ...paths);
 
 	const disposables = new DisposableStore();
 
-	setup(async () => {
+	const initFixtures = async () => {
+		await Promise.all(
+			[['fixtures', 'resolver', 'examples'],
+			['fixtures', 'resolver', 'other', 'deep'],
+			['fixtures', 'service', 'deep'],
+			['batched']]
+				.map(path => userdataURIFromPaths(path))
+				.map(uri => service.createFolder(uri)));
+		await Promise.all(
+			([
+				[['fixtures', 'resolver', 'examples', 'company.js'], 'class company {}'],
+				[['fixtures', 'resolver', 'examples', 'conway.js'], 'export function conway() {}'],
+				[['fixtures', 'resolver', 'examples', 'employee.js'], 'export const employee = "jax"'],
+				[['fixtures', 'resolver', 'examples', 'small.js'], ''],
+				[['fixtures', 'resolver', 'other', 'deep', 'company.js'], 'class company {}'],
+				[['fixtures', 'resolver', 'other', 'deep', 'conway.js'], 'export function conway() {}'],
+				[['fixtures', 'resolver', 'other', 'deep', 'employee.js'], 'export const employee = "jax"'],
+				[['fixtures', 'resolver', 'other', 'deep', 'small.js'], ''],
+				[['fixtures', 'resolver', 'index.html'], '<p>p</p>'],
+				[['fixtures', 'resolver', 'site.css'], '.p {color: red;}'],
+				[['fixtures', 'service', 'deep', 'company.js'], 'class company {}'],
+				[['fixtures', 'service', 'deep', 'conway.js'], 'export function conway() {}'],
+				[['fixtures', 'service', 'deep', 'employee.js'], 'export const employee = "jax"'],
+				[['fixtures', 'service', 'deep', 'small.js'], ''],
+				[['fixtures', 'service', 'binary.txt'], '<p>p</p>'],
+			] as const)
+				.map(([path, contents]) => [userdataURIFromPaths(path), contents] as const)
+				.map(([uri, contents]) => service.createFile(uri, VSBuffer.fromString(contents)))
+		);
+	};
+
+	const reload = async () => {
 		const logService = new NullLogService();
 
 		service = new FileService(logService);
@@ -45,28 +73,36 @@ suite('IndexedDB File Service', function () {
 		userdataFileProvider = assertIsDefined(await new IndexedDB().createFileSystemProvider(logSchema, INDEXEDDB_USERDATA_OBJECT_STORE));
 		disposables.add(service.registerProvider(Schemas.userData, userdataFileProvider));
 		disposables.add(userdataFileProvider);
+	};
+
+	setup(async () => {
+		await reload();
 	});
 
 	teardown(async () => {
 		disposables.clear();
+		await logFileProvider.delete(logfileURIFromPaths([]), { recursive: true, useTrash: false });
+		await userdataFileProvider.delete(userdataURIFromPaths([]), { recursive: true, useTrash: false });
+	});
 
-		await logFileProvider.delete(makeLogfileURI(testDir), { recursive: true, useTrash: false });
-		await userdataFileProvider.delete(makeUserdataURI(testDir), { recursive: true, useTrash: false });
+	test('root is always present', async () => {
+		assert.equal((await userdataFileProvider.stat(userdataURIFromPaths([]))).type, FileType.Directory);
+		await userdataFileProvider.delete(userdataURIFromPaths([]), { recursive: true, useTrash: false });
+		assert.equal((await userdataFileProvider.stat(userdataURIFromPaths([]))).type, FileType.Directory);
 	});
 
 	test('createFolder', async () => {
 		let event: FileOperationEvent | undefined;
 		disposables.add(service.onDidRunOperation(e => event = e));
 
-		const parent = await service.resolve(makeUserdataURI(testDir));
-
-		const newFolderResource = makeUserdataURI(join(parent.resource.path, 'newFolder'));
+		const parent = await service.resolve(userdataURIFromPaths([]));
+		const newFolderResource = joinPath(parent.resource, 'newFolder');
 
 		assert.equal((await userdataFileProvider.readdir(parent.resource)).length, 0);
 		const newFolder = await service.createFolder(newFolderResource);
 		assert.equal(newFolder.name, 'newFolder');
-		// Invalid.. dirs dont exist in our IDBFSB.
-		// assert.equal((await userdataFileProvider.readdir(parent.resource)).length, 1);
+		assert.equal((await userdataFileProvider.readdir(parent.resource)).length, 1);
+		assert.equal((await userdataFileProvider.stat(newFolderResource)).type, FileType.Directory);
 
 		assert.ok(event);
 		assert.equal(event!.resource.path, newFolderResource.path);
@@ -74,4 +110,229 @@ suite('IndexedDB File Service', function () {
 		assert.equal(event!.target!.resource.path, newFolderResource.path);
 		assert.equal(event!.target!.isDirectory, true);
 	});
+
+	test('createFolder: creating multiple folders at once', async () => {
+		let event: FileOperationEvent;
+		disposables.add(service.onDidRunOperation(e => event = e));
+
+		const multiFolderPaths = ['a', 'couple', 'of', 'folders'];
+		const parent = await service.resolve(userdataURIFromPaths([]));
+		const newFolderResource = joinPath(parent.resource, ...multiFolderPaths);
+
+		const newFolder = await service.createFolder(newFolderResource);
+
+		const lastFolderName = multiFolderPaths[multiFolderPaths.length - 1];
+		assert.equal(newFolder.name, lastFolderName);
+		assert.equal((await userdataFileProvider.stat(newFolderResource)).type, FileType.Directory);
+
+		assert.ok(event!);
+		assert.equal(event!.resource.path, newFolderResource.path);
+		assert.equal(event!.operation, FileOperation.CREATE);
+		assert.equal(event!.target!.resource.path, newFolderResource.path);
+		assert.equal(event!.target!.isDirectory, true);
+	});
+
+	test('exists', async () => {
+		let exists = await service.exists(userdataURIFromPaths([]));
+		assert.equal(exists, true);
+
+		exists = await service.exists(userdataURIFromPaths(['hello']));
+		assert.equal(exists, false);
+	});
+
+	test('resolve - file', async () => {
+		await initFixtures();
+
+		const resource = userdataURIFromPaths(['fixtures', 'resolver', 'index.html']);
+		const resolved = await service.resolve(resource);
+
+		assert.equal(resolved.name, 'index.html');
+		assert.equal(resolved.isFile, true);
+		assert.equal(resolved.isDirectory, false);
+		assert.equal(resolved.isSymbolicLink, false);
+		assert.equal(resolved.resource.toString(), resource.toString());
+		assert.equal(resolved.children, undefined);
+		assert.ok(resolved.size! > 0);
+	});
+
+	test('resolve - directory', async () => {
+		await initFixtures();
+
+		const testsElements = ['examples', 'other', 'index.html', 'site.css'];
+
+		const resource = userdataURIFromPaths(['fixtures', 'resolver']);
+		const result = await service.resolve(resource);
+
+		assert.ok(result);
+		assert.equal(result.resource.toString(), resource.toString());
+		assert.equal(result.name, 'resolver');
+		assert.ok(result.children);
+		assert.ok(result.children!.length > 0);
+		assert.ok(result!.isDirectory);
+		assert.equal(result.children!.length, testsElements.length);
+
+		assert.ok(result.children!.every(entry => {
+			return testsElements.some(name => {
+				return basename(entry.resource) === name;
+			});
+		}));
+
+		result.children!.forEach(value => {
+			assert.ok(basename(value.resource));
+			if (['examples', 'other'].indexOf(basename(value.resource)) >= 0) {
+				assert.ok(value.isDirectory);
+				assert.equal(value.mtime, undefined);
+				assert.equal(value.ctime, undefined);
+			} else if (basename(value.resource) === 'index.html') {
+				assert.ok(!value.isDirectory);
+				assert.ok(!value.children);
+				assert.equal(value.mtime, undefined);
+				assert.equal(value.ctime, undefined);
+			} else if (basename(value.resource) === 'site.css') {
+				assert.ok(!value.isDirectory);
+				assert.ok(!value.children);
+				assert.equal(value.mtime, undefined);
+				assert.equal(value.ctime, undefined);
+			} else {
+				assert.ok(!'Unexpected value ' + basename(value.resource));
+			}
+		});
+	});
+
+	test('createFile', async () => {
+		return assertCreateFile(contents => VSBuffer.fromString(contents));
+	});
+
+	test('createFile (readable)', async () => {
+		return assertCreateFile(contents => bufferToReadable(VSBuffer.fromString(contents)));
+	});
+
+	test('createFile (stream)', async () => {
+		return assertCreateFile(contents => bufferToStream(VSBuffer.fromString(contents)));
+	});
+
+	async function assertCreateFile(converter: (content: string) => VSBuffer | VSBufferReadable | VSBufferReadableStream): Promise<void> {
+		let event: FileOperationEvent;
+		disposables.add(service.onDidRunOperation(e => event = e));
+
+		const contents = 'Hello World';
+		const resource = userdataURIFromPaths(['test.txt']);
+
+		assert.equal(await service.canCreateFile(resource), true);
+		const fileStat = await service.createFile(resource, converter(contents));
+		assert.equal(fileStat.name, 'test.txt');
+		assert.equal((await userdataFileProvider.stat(fileStat.resource)).type, FileType.File);
+		assert.equal(new TextDecoder().decode(await userdataFileProvider.readFile(fileStat.resource)), contents);
+
+		assert.ok(event!);
+		assert.equal(event!.resource.path, resource.path);
+		assert.equal(event!.operation, FileOperation.CREATE);
+		assert.equal(event!.target!.resource.path, resource.path);
+	}
+
+	// This may be flakey on build machines. If so please disable and ping me (jackson) and we can try an alternative approach (probably exposing more internal state from the FSP)
+	test('createFile (batched)', async () => {
+		// Batched writes take approx .5ms/file, sequenced take approx 10ms/file.
+		// Testing with 1000 files would take ~10s without batching (exceeds 5s timeout), or 500ms with (well winthin 5s timeout)
+		const batch = Array.from({ length: 1000 }).map((_, i) => ({ contents: `Hello${i}`, resource: userdataURIFromPaths(['batched', `Hello${i}.txt`]) }));
+		const stats = await Promise.all(batch.map(entry => service.createFile(entry.resource, VSBuffer.fromString(entry.contents))));
+		for (let i = 0; i < stats.length; i++) {
+			const entry = batch[i];
+			const stat = stats[i];
+			assert.equal(stat.name, `Hello${i}.txt`);
+			assert.equal((await userdataFileProvider.stat(stat.resource)).type, FileType.File);
+			assert.equal(new TextDecoder().decode(await userdataFileProvider.readFile(stat.resource)), entry.contents);
+		}
+		await service.del(userdataURIFromPaths(['batched']), { recursive: true, useTrash: false });
+		await Promise.all(stats.map(async stat => {
+			const newStat = await userdataFileProvider.stat(stat.resource).catch(e => e.code);
+			assert.equal(newStat, FileSystemProviderErrorCode.FileNotFound);
+		}));
+	});
+
+	test('deleteFile', async () => {
+		await initFixtures();
+
+		let event: FileOperationEvent;
+		disposables.add(service.onDidRunOperation(e => event = e));
+
+		const anotherResource = userdataURIFromPaths(['fixtures', 'service', 'deep', 'company.js']);
+		const resource = userdataURIFromPaths(['fixtures', 'service', 'deep', 'conway.js']);
+		const source = await service.resolve(resource);
+
+		assert.equal(await service.canDelete(source.resource, { useTrash: false }), true);
+		await service.del(source.resource, { useTrash: false });
+
+		assert.equal(await service.exists(source.resource), false);
+		assert.equal(await service.exists(anotherResource), true);
+
+		assert.ok(event!);
+		assert.equal(event!.resource.path, resource.path);
+		assert.equal(event!.operation, FileOperation.DELETE);
+
+		{
+			let error: Error | undefined = undefined;
+			try {
+				await service.del(source.resource, { useTrash: false });
+			} catch (e) {
+				error = e;
+			}
+
+			assert.ok(error);
+			assert.equal((<FileOperationError>error).fileOperationResult, FileOperationResult.FILE_NOT_FOUND);
+		}
+		await reload();
+		{
+			let error: Error | undefined = undefined;
+			try {
+				await service.del(source.resource, { useTrash: false });
+			} catch (e) {
+				error = e;
+			}
+
+			assert.ok(error);
+			assert.equal((<FileOperationError>error).fileOperationResult, FileOperationResult.FILE_NOT_FOUND);
+		}
+	});
+
+	test('deleteFolder (recursive)', async () => {
+		await initFixtures();
+		let event: FileOperationEvent;
+		disposables.add(service.onDidRunOperation(e => event = e));
+
+		const resource = userdataURIFromPaths(['fixtures', 'service', 'deep']);
+		const subResource1 = userdataURIFromPaths(['fixtures', 'service', 'deep', 'company.js']);
+		const subResource2 = userdataURIFromPaths(['fixtures', 'service', 'deep', 'conway.js']);
+		assert.equal(await service.exists(subResource1), true);
+		assert.equal(await service.exists(subResource2), true);
+
+		const source = await service.resolve(resource);
+
+		assert.equal(await service.canDelete(source.resource, { recursive: true, useTrash: false }), true);
+		await service.del(source.resource, { recursive: true, useTrash: false });
+
+		assert.equal(await service.exists(source.resource), false);
+		assert.equal(await service.exists(subResource1), false);
+		assert.equal(await service.exists(subResource2), false);
+		assert.ok(event!);
+		assert.equal(event!.resource.fsPath, resource.fsPath);
+		assert.equal(event!.operation, FileOperation.DELETE);
+	});
+
+
+	test('deleteFolder (non recursive)', async () => {
+		await initFixtures();
+		const resource = userdataURIFromPaths(['fixtures', 'service', 'deep']);
+		const source = await service.resolve(resource);
+
+		assert.ok((await service.canDelete(source.resource)) instanceof Error);
+
+		let error;
+		try {
+			await service.del(source.resource);
+		} catch (e) {
+			error = e;
+		}
+		assert.ok(error);
+	});
 });