vault-link/frontend/test-client/src/agent/mock-client.ts

198 lines
5.1 KiB
TypeScript

import type { StoredDatabase } from "sync-client";
import { assert } from "../utils/assert";
import {
type RelativePath,
type FileSystemOperations,
type SyncSettings,
SyncClient
} from "sync-client";
import type { TextWithCursors } from "reconcile-text";
export class MockClient implements FileSystemOperations {
protected readonly localFiles = new Map<string, Uint8Array>();
protected client!: SyncClient;
protected data: Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {
database: {
// Assume all clients start at the same time so there's no need to fetch
// any shared state.
hasInitialSyncCompleted: true
}
};
public constructor(
initialSettings: Partial<SyncSettings>,
protected readonly useSlowFileEvents: boolean
) {
this.data.settings = initialSettings;
}
public async init(
fetchImplementation: typeof globalThis.fetch,
webSocketImplementation: typeof globalThis.WebSocket
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
persistence: {
load: async () => this.data,
save: async (data) => void (this.data = data)
},
fetch: fetchImplementation,
webSocket: webSocketImplementation
});
await this.client.start();
}
public async listFilesRecursively(
_root: RelativePath | undefined = undefined // we don't use multi-level paths during tests
): Promise<RelativePath[]> {
return Array.from(this.localFiles.keys());
}
public async read(path: RelativePath): Promise<Uint8Array> {
const file = this.localFiles.get(path);
if (!file) {
throw new Error(`File ${path} does not exist`);
}
return file;
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.read(path)).length;
}
public async exists(path: RelativePath): Promise<boolean> {
return this.localFiles.has(path);
}
public async create(
path: RelativePath,
newContent: Uint8Array
): Promise<void> {
if (this.localFiles.has(path)) {
throw new Error(`File ${path} already exists`);
}
this.client.logger.info(
`Creating file ${path} with content ${new TextDecoder().decode(newContent)}`
);
this.localFiles.set(path, newContent);
this.executeFileOperation(async () =>
this.client.syncLocallyCreatedFile(path)
);
}
public async createDirectory(_path: RelativePath): Promise<void> {
// This doesn't mean anything in our virtual FS representation
}
public async atomicUpdateText(
path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors
): Promise<string> {
const file = this.localFiles.get(path);
if (!file) {
throw new Error(`File ${path} does not exist`);
}
const currentContent = new TextDecoder().decode(file);
const newContent = updater({ text: currentContent, cursors: [] }).text;
const newContentUint8Array = new TextEncoder().encode(newContent);
this.localFiles.set(path, newContentUint8Array);
if (!this.useSlowFileEvents) {
const existingParts = currentContent
.split(" ")
.map((part) => part.trim());
const newParts = newContent.split(" ").map((part) => part.trim());
existingParts.forEach((part) =>
// all changes should be additive
{
assert(
newParts.includes(part),
`Part ${part} not found in new content: ${newContent}`
);
}
);
}
this.client.logger.info(
`Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}`
);
this.executeFileOperation(async () =>
this.client.syncLocallyUpdatedFile({
relativePath: path
})
);
return newContent;
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
const hasExisted = this.localFiles.has(path);
this.localFiles.set(path, content);
this.client.logger.info(
`Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}`
);
this.executeFileOperation(async () => {
if (hasExisted) {
return this.client.syncLocallyUpdatedFile({
relativePath: path
});
} else {
return this.client.syncLocallyCreatedFile(path);
}
});
}
public async delete(path: RelativePath): Promise<void> {
this.client.logger.info(
`Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}`
);
this.localFiles.delete(path);
this.executeFileOperation(async () =>
this.client.syncLocallyDeletedFile(path)
);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const file = this.localFiles.get(oldPath);
if (!file) {
throw new Error(`File ${oldPath} does not exist`);
}
this.localFiles.set(newPath, file);
if (oldPath !== newPath) {
this.localFiles.delete(oldPath);
}
this.client.logger.info(
`Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}`
);
this.executeFileOperation(async () =>
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
);
}
private executeFileOperation(callback: () => unknown): void {
if (this.useSlowFileEvents) {
// we aren't the best client and it takes some time to notice changes
setTimeout(callback, Math.random() * 100);
} else {
callback();
}
}
}