vault-link/frontend/test-client/src/agent/mock-client.ts
Andras Schmelczer 0daeaf6382 split: deterministic-tests, obsidian-plugin, local-cli, test-client, frontend root
New deterministic-tests workspace: scripted multi-client harness against
a real server (~110 scenario tests, server-control, managed-websocket,
test-runner). Updates to existing workspaces: obsidian-plugin (settings,
cursors, plugin entrypoint), local-client-cli (args, cli, file-watcher,
node-filesystem, path-utils + tests), test-client (mock-agent/client,
cli, error tracker). Bumps frontend root package.json/lock and adds
eslint config tweaks.
2026-05-08 21:37:51 +01:00

125 lines
3.8 KiB
TypeScript

import type { StoredDatabase, TextWithCursors } from "sync-client";
import { assert } from "../utils/assert";
import {
type RelativePath,
type SyncSettings,
SyncClient,
debugging
} from "sync-client";
export class MockClient extends debugging.InMemoryFileSystem {
protected client!: SyncClient;
protected data: Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {};
private slowEventChain: Promise<void> = Promise.resolve();
public constructor(
initialSettings: Partial<SyncSettings>,
protected readonly useSlowFileEvents: boolean
) {
super();
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 override async write(
path: RelativePath,
content: Uint8Array
): Promise<void> {
const isNew = !this.files.has(path);
this.files.set(path, content);
if (isNew) {
this.executeFileOperation(async () => {
this.client.syncLocallyCreatedFile(path);
});
} else {
this.executeFileOperation(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
}
public override async atomicUpdateText(
path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors
): Promise<string> {
const file = this.files.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.files.set(path, newContentUint8Array);
this.executeFileOperation(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
return newContent;
}
public override async delete(path: RelativePath): Promise<void> {
this.files.delete(path);
this.executeFileOperation(async () => {
this.client.syncLocallyDeletedFile(path);
});
}
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const file = this.files.get(oldPath);
if (!file) {
throw new Error(`File ${oldPath} does not exist`);
}
this.files.set(newPath, file);
if (oldPath !== newPath) {
this.files.delete(oldPath);
}
this.executeFileOperation(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
});
}
protected executeFileOperation(callback: () => unknown): void {
if (this.useSlowFileEvents) {
// we aren't the best client and it takes some time to notice
// changes, but they still arrive in the order they happened
this.slowEventChain = this.slowEventChain.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 100)
);
await callback();
});
} else {
callback();
}
}
}