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.
This commit is contained in:
Andras Schmelczer 2026-05-08 21:37:51 +01:00
parent 5a070340f1
commit 0daeaf6382
162 changed files with 10687 additions and 4051 deletions

View file

@ -2,30 +2,26 @@ import type { StoredDatabase, TextWithCursors } from "sync-client";
import { assert } from "../utils/assert";
import {
type RelativePath,
type FileSystemOperations,
type SyncSettings,
SyncClient
SyncClient,
debugging
} from "sync-client";
export class MockClient implements FileSystemOperations {
protected readonly localFiles = new Map<string, Uint8Array>();
export class MockClient extends debugging.InMemoryFileSystem {
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
}
};
}> = {};
private slowEventChain: Promise<void> = Promise.resolve();
public constructor(
initialSettings: Partial<SyncSettings>,
protected readonly useSlowFileEvents: boolean
) {
super();
this.data.settings = initialSettings;
}
@ -46,150 +42,82 @@ export class MockClient implements FileSystemOperations {
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(
public override async write(
path: RelativePath,
newContent: Uint8Array
content: Uint8Array
): Promise<void> {
if (this.localFiles.has(path)) {
throw new Error(`File ${path} already exists`);
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 });
});
}
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(
public override async atomicUpdateText(
path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors
): Promise<string> {
const file = this.localFiles.get(path);
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.localFiles.set(path, newContentUint8Array);
this.files.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
})
);
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)}`
);
public override async delete(path: RelativePath): Promise<void> {
this.files.delete(path);
this.executeFileOperation(async () => {
if (hasExisted) {
return this.client.syncLocallyUpdatedFile({
relativePath: path
});
} else {
return this.client.syncLocallyCreatedFile(path);
}
this.client.syncLocallyDeletedFile(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(
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
const file = this.localFiles.get(oldPath);
const file = this.files.get(oldPath);
if (!file) {
throw new Error(`File ${oldPath} does not exist`);
}
this.localFiles.set(newPath, file);
this.files.set(newPath, file);
if (oldPath !== newPath) {
this.localFiles.delete(oldPath);
this.files.delete(oldPath);
}
this.client.logger.info(
`Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}`
);
this.executeFileOperation(async () =>
this.executeFileOperation(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
);
});
});
}
private executeFileOperation(callback: () => unknown): void {
protected 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);
// 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();
}