.
This commit is contained in:
parent
829a16aa77
commit
9015c78598
9 changed files with 1097 additions and 8 deletions
|
|
@ -123,6 +123,9 @@ export class Locks<T> {
|
||||||
*/
|
*/
|
||||||
public unlock(key: T): void {
|
public unlock(key: T): void {
|
||||||
if (!this.locked.has(key)) {
|
if (!this.locked.has(key)) {
|
||||||
|
this.logger?.warn(
|
||||||
|
`Attempted to unlock key "${key}" which is not currently locked`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack watch --mode development",
|
"dev": "webpack watch --mode development",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test src/**/*.test.ts"
|
"test": "tsx --test src/**/*.test.ts",
|
||||||
|
"test:deterministic": "npm run build && node dist/deterministic/cli.js",
|
||||||
|
"test:fuzzing": "npm run build && node dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"@types/node": "^24.8.1",
|
||||||
|
|
|
||||||
68
frontend/test-client/src/deterministic/cli.ts
Normal file
68
frontend/test-client/src/deterministic/cli.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { DeterministicTestRunner } from "./test-runner";
|
||||||
|
import { exampleTests } from "./example-tests";
|
||||||
|
|
||||||
|
const REMOTE_URI = "http://localhost:3000";
|
||||||
|
const TOKEN = "test-token-change-me";
|
||||||
|
|
||||||
|
async function runDeterministicTests(): Promise<void> {
|
||||||
|
console.info("=".repeat(80));
|
||||||
|
console.info("DETERMINISTIC E2E TESTS");
|
||||||
|
console.info("=".repeat(80));
|
||||||
|
console.info("");
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const testDef of exampleTests) {
|
||||||
|
// Use a unique vault for each test to avoid interference
|
||||||
|
const vaultName = uuidv4();
|
||||||
|
const runner = new DeterministicTestRunner(
|
||||||
|
vaultName,
|
||||||
|
REMOTE_URI,
|
||||||
|
TOKEN
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runner.runTest(testDef);
|
||||||
|
passed++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(`Test "${testDef.name}" failed with error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("\n" + "=".repeat(80));
|
||||||
|
console.info("TEST SUMMARY");
|
||||||
|
console.info("=".repeat(80));
|
||||||
|
console.info(`Total tests: ${exampleTests.length}`);
|
||||||
|
console.info(`Passed: ${passed}`);
|
||||||
|
console.info(`Failed: ${failed}`);
|
||||||
|
console.info("=".repeat(80));
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handlers
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
console.error("Uncaught exception:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (error) => {
|
||||||
|
console.error("Unhandled rejection:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
runDeterministicTests()
|
||||||
|
.then(() => {
|
||||||
|
console.info("\n✓ All deterministic tests passed!");
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("\n✗ Deterministic tests failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
241
frontend/test-client/src/deterministic/deterministic-client.ts
Normal file
241
frontend/test-client/src/deterministic/deterministic-client.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
import type { RelativePath, SyncSettings } from "sync-client";
|
||||||
|
import { MockClient } from "../agent/mock-client";
|
||||||
|
import { assert } from "../utils/assert";
|
||||||
|
|
||||||
|
export class DeterministicClient extends MockClient {
|
||||||
|
private pendingOperations: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly clientId: string,
|
||||||
|
initialSettings: Partial<SyncSettings>
|
||||||
|
) {
|
||||||
|
super(initialSettings, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying SyncClient
|
||||||
|
*/
|
||||||
|
public getSyncClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a file with specific content
|
||||||
|
*/
|
||||||
|
public async createFile(
|
||||||
|
path: RelativePath,
|
||||||
|
content: string,
|
||||||
|
immediate = true
|
||||||
|
): Promise<void> {
|
||||||
|
const operation = async (): Promise<void> => {
|
||||||
|
await this.create(path, new TextEncoder().encode(content));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
await operation();
|
||||||
|
} else {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a file with new content (replaces all content)
|
||||||
|
*/
|
||||||
|
public async updateFile(
|
||||||
|
path: RelativePath,
|
||||||
|
content: string,
|
||||||
|
immediate = true
|
||||||
|
): Promise<void> {
|
||||||
|
const operation = async (): Promise<void> => {
|
||||||
|
await this.write(path, new TextEncoder().encode(content));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
await operation();
|
||||||
|
} else {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append content to a file
|
||||||
|
*/
|
||||||
|
public async appendToFile(
|
||||||
|
path: RelativePath,
|
||||||
|
content: string,
|
||||||
|
immediate = true
|
||||||
|
): Promise<void> {
|
||||||
|
const operation = async (): Promise<void> => {
|
||||||
|
await this.atomicUpdateText(path, (current) => ({
|
||||||
|
text: current.text + content,
|
||||||
|
cursors: []
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
await operation();
|
||||||
|
} else {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file
|
||||||
|
*/
|
||||||
|
public async deleteFile(
|
||||||
|
path: RelativePath,
|
||||||
|
immediate = true
|
||||||
|
): Promise<void> {
|
||||||
|
const operation = async (): Promise<void> => {
|
||||||
|
await this.delete(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
await operation();
|
||||||
|
} else {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a file
|
||||||
|
*/
|
||||||
|
public async renameFile(
|
||||||
|
oldPath: RelativePath,
|
||||||
|
newPath: RelativePath,
|
||||||
|
immediate = true
|
||||||
|
): Promise<void> {
|
||||||
|
const operation = async (): Promise<void> => {
|
||||||
|
await this.rename(oldPath, newPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
await operation();
|
||||||
|
} else {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all pending operations
|
||||||
|
*/
|
||||||
|
public async flush(): Promise<void> {
|
||||||
|
const operations = [...this.pendingOperations];
|
||||||
|
this.pendingOperations = [];
|
||||||
|
|
||||||
|
for (const operation of operations) {
|
||||||
|
await operation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until all sync operations are complete
|
||||||
|
*/
|
||||||
|
public async waitForSync(): Promise<void> {
|
||||||
|
await this.client.waitUntilFinished();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable sync
|
||||||
|
*/
|
||||||
|
public async setSyncEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await this.client.setSetting("isSyncEnabled", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file content as string
|
||||||
|
*/
|
||||||
|
public async getFileContent(path: RelativePath): Promise<string> {
|
||||||
|
const content = await this.read(path);
|
||||||
|
return new TextDecoder().decode(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of files
|
||||||
|
*/
|
||||||
|
public async getFileCount(): Promise<number> {
|
||||||
|
const files = await this.listFilesRecursively();
|
||||||
|
return files.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert file exists or doesn't exist
|
||||||
|
*/
|
||||||
|
public async assertFileExists(
|
||||||
|
path: RelativePath,
|
||||||
|
shouldExist: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
exists === shouldExist,
|
||||||
|
`[${this.clientId}] Expected file ${path} to ${shouldExist ? "exist" : "not exist"}, but it ${exists ? "exists" : "doesn't exist"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert file content matches expected
|
||||||
|
*/
|
||||||
|
public async assertFileContent(
|
||||||
|
path: RelativePath,
|
||||||
|
expectedContent: string
|
||||||
|
): Promise<void> {
|
||||||
|
const content = await this.getFileContent(path);
|
||||||
|
assert(
|
||||||
|
content === expectedContent,
|
||||||
|
`[${this.clientId}] Expected file ${path} to have content "${expectedContent}", but it has "${content}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert file count matches expected
|
||||||
|
*/
|
||||||
|
public async assertFileCount(expectedCount: number): Promise<void> {
|
||||||
|
const count = await this.getFileCount();
|
||||||
|
assert(
|
||||||
|
count === expectedCount,
|
||||||
|
`[${this.clientId}] Expected ${expectedCount} files, but found ${count}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this client's filesystem is consistent with another client
|
||||||
|
*/
|
||||||
|
public async assertConsistentWith(
|
||||||
|
otherClient: DeterministicClient
|
||||||
|
): Promise<void> {
|
||||||
|
const thisFiles = await this.listFilesRecursively();
|
||||||
|
const otherFiles = await otherClient.listFilesRecursively();
|
||||||
|
|
||||||
|
const thisFilesSet = new Set(thisFiles);
|
||||||
|
const otherFilesSet = new Set(otherFiles);
|
||||||
|
|
||||||
|
const missingInOther = thisFiles.filter((f) => !otherFilesSet.has(f));
|
||||||
|
const missingInThis = otherFiles.filter((f) => !thisFilesSet.has(f));
|
||||||
|
|
||||||
|
assert(
|
||||||
|
missingInOther.length === 0,
|
||||||
|
`[${this.clientId}] Files missing in ${otherClient.clientId}: ${missingInOther.join(", ")}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
missingInThis.length === 0,
|
||||||
|
`[${this.clientId}] Files missing in this client from ${otherClient.clientId}: ${missingInThis.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check content of all files
|
||||||
|
for (const file of thisFiles) {
|
||||||
|
const thisContent = await this.getFileContent(file);
|
||||||
|
const otherContent = await otherClient.getFileContent(file);
|
||||||
|
assert(
|
||||||
|
thisContent === otherContent,
|
||||||
|
`[${this.clientId}] Content mismatch for ${file}:\n This: "${thisContent}"\n Other: "${otherContent}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
public async destroy(): Promise<void> {
|
||||||
|
await this.client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
143
frontend/test-client/src/deterministic/events.ts
Normal file
143
frontend/test-client/src/deterministic/events.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import type { RelativePath } from "sync-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base event interface
|
||||||
|
*/
|
||||||
|
export interface BaseEvent {
|
||||||
|
type: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File operation events
|
||||||
|
*/
|
||||||
|
export interface CreateFileEvent extends BaseEvent {
|
||||||
|
type: "create-file";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
content: string;
|
||||||
|
immediate?: boolean; // If true, sync immediately; if false, defer until flush
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFileEvent extends BaseEvent {
|
||||||
|
type: "update-file";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
content: string;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteFileEvent extends BaseEvent {
|
||||||
|
type: "delete-file";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameFileEvent extends BaseEvent {
|
||||||
|
type: "rename-file";
|
||||||
|
clientId: string;
|
||||||
|
oldPath: RelativePath;
|
||||||
|
newPath: RelativePath;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppendToFileEvent extends BaseEvent {
|
||||||
|
type: "append-to-file";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
content: string;
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync control events
|
||||||
|
*/
|
||||||
|
export interface FlushEvent extends BaseEvent {
|
||||||
|
type: "flush";
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitForSyncEvent extends BaseEvent {
|
||||||
|
type: "wait-for-sync";
|
||||||
|
clientId?: string; // If undefined, wait for all clients
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnableSyncEvent extends BaseEvent {
|
||||||
|
type: "enable-sync";
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisableSyncEvent extends BaseEvent {
|
||||||
|
type: "disable-sync";
|
||||||
|
clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timing events
|
||||||
|
*/
|
||||||
|
export interface SleepEvent extends BaseEvent {
|
||||||
|
type: "sleep";
|
||||||
|
milliseconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assertion events
|
||||||
|
*/
|
||||||
|
export interface AssertFileExistsEvent extends BaseEvent {
|
||||||
|
type: "assert-file-exists";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
shouldExist: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssertFileContentEvent extends BaseEvent {
|
||||||
|
type: "assert-file-content";
|
||||||
|
clientId: string;
|
||||||
|
path: RelativePath;
|
||||||
|
expectedContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssertFileCountEvent extends BaseEvent {
|
||||||
|
type: "assert-file-count";
|
||||||
|
clientId: string;
|
||||||
|
expectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssertAllClientsConsistentEvent extends BaseEvent {
|
||||||
|
type: "assert-all-clients-consistent";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssertClientsConsistentEvent extends BaseEvent {
|
||||||
|
type: "assert-clients-consistent";
|
||||||
|
clientIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type of all events
|
||||||
|
*/
|
||||||
|
export type TestEvent =
|
||||||
|
| CreateFileEvent
|
||||||
|
| UpdateFileEvent
|
||||||
|
| DeleteFileEvent
|
||||||
|
| RenameFileEvent
|
||||||
|
| AppendToFileEvent
|
||||||
|
| FlushEvent
|
||||||
|
| WaitForSyncEvent
|
||||||
|
| EnableSyncEvent
|
||||||
|
| DisableSyncEvent
|
||||||
|
| SleepEvent
|
||||||
|
| AssertFileExistsEvent
|
||||||
|
| AssertFileContentEvent
|
||||||
|
| AssertFileCountEvent
|
||||||
|
| AssertAllClientsConsistentEvent
|
||||||
|
| AssertClientsConsistentEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test definition
|
||||||
|
*/
|
||||||
|
export interface TestDefinition {
|
||||||
|
name: string;
|
||||||
|
clients: string[]; // Client IDs
|
||||||
|
events: TestEvent[];
|
||||||
|
}
|
||||||
350
frontend/test-client/src/deterministic/example-tests.ts
Normal file
350
frontend/test-client/src/deterministic/example-tests.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import type { TestDefinition } from "./events";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple test: Create a file on one client and verify it syncs to another
|
||||||
|
*/
|
||||||
|
export const simpleSync: TestDefinition = {
|
||||||
|
name: "Simple sync between two clients",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "test.md",
|
||||||
|
content: "Hello, world!",
|
||||||
|
description: "Client 1 creates a file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for all clients to sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "test.md",
|
||||||
|
shouldExist: true,
|
||||||
|
description: "Verify file exists on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-content",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "test.md",
|
||||||
|
expectedContent: "Hello, world!",
|
||||||
|
description: "Verify content matches on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify all clients are consistent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test concurrent edits to the same file
|
||||||
|
*/
|
||||||
|
export const concurrentEdits: TestDefinition = {
|
||||||
|
name: "Concurrent edits with operational transformation",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "collaborative.md",
|
||||||
|
content: "Initial content ",
|
||||||
|
description: "Client 1 creates initial file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "disable-sync",
|
||||||
|
clientId: "client1",
|
||||||
|
description: "Disable sync on Client 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "disable-sync",
|
||||||
|
clientId: "client2",
|
||||||
|
description: "Disable sync on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "append-to-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "collaborative.md",
|
||||||
|
content: "EditA ",
|
||||||
|
description: "Client 1 appends offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "append-to-file",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "collaborative.md",
|
||||||
|
content: "EditB ",
|
||||||
|
description: "Client 2 appends offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "enable-sync",
|
||||||
|
clientId: "client1",
|
||||||
|
description: "Re-enable sync on Client 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "enable-sync",
|
||||||
|
clientId: "client2",
|
||||||
|
description: "Re-enable sync on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for conflict resolution"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify both clients converged to same state"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test file deletion propagation
|
||||||
|
*/
|
||||||
|
export const fileDeletion: TestDefinition = {
|
||||||
|
name: "File deletion syncs correctly",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "to-delete.md",
|
||||||
|
content: "This file will be deleted",
|
||||||
|
description: "Client 1 creates a file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "to-delete.md",
|
||||||
|
shouldExist: true,
|
||||||
|
description: "Verify file exists on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "delete-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "to-delete.md",
|
||||||
|
description: "Client 1 deletes the file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for deletion to sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "to-delete.md",
|
||||||
|
shouldExist: false,
|
||||||
|
description: "Verify file deleted on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify all clients are consistent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test file rename propagation
|
||||||
|
*/
|
||||||
|
export const fileRename: TestDefinition = {
|
||||||
|
name: "File rename syncs correctly",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "old-name.md",
|
||||||
|
content: "Content that should persist",
|
||||||
|
description: "Client 1 creates a file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "old-name.md",
|
||||||
|
shouldExist: true,
|
||||||
|
description: "Verify file exists on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename-file",
|
||||||
|
clientId: "client1",
|
||||||
|
oldPath: "old-name.md",
|
||||||
|
newPath: "new-name.md",
|
||||||
|
description: "Client 1 renames the file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for rename to sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "old-name.md",
|
||||||
|
shouldExist: false,
|
||||||
|
description: "Verify old name doesn't exist on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-exists",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "new-name.md",
|
||||||
|
shouldExist: true,
|
||||||
|
description: "Verify new name exists on Client 2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-content",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "new-name.md",
|
||||||
|
expectedContent: "Content that should persist",
|
||||||
|
description: "Verify content preserved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify all clients are consistent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test deferred operations (batching)
|
||||||
|
*/
|
||||||
|
export const deferredOperations: TestDefinition = {
|
||||||
|
name: "Deferred operations batch correctly",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "file1.md",
|
||||||
|
content: "File 1",
|
||||||
|
immediate: false,
|
||||||
|
description: "Queue creation of file 1 (not synced yet)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "file2.md",
|
||||||
|
content: "File 2",
|
||||||
|
immediate: false,
|
||||||
|
description: "Queue creation of file 2 (not synced yet)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "file3.md",
|
||||||
|
content: "File 3",
|
||||||
|
immediate: false,
|
||||||
|
description: "Queue creation of file 3 (not synced yet)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "sleep",
|
||||||
|
milliseconds: 100,
|
||||||
|
description: "Wait a bit (files shouldn't sync yet)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-count",
|
||||||
|
clientId: "client2",
|
||||||
|
expectedCount: 0,
|
||||||
|
description: "Verify Client 2 has no files yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "flush",
|
||||||
|
clientId: "client1",
|
||||||
|
description: "Flush pending operations on Client 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for all files to sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-file-count",
|
||||||
|
clientId: "client2",
|
||||||
|
expectedCount: 3,
|
||||||
|
description: "Verify Client 2 now has all 3 files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify all clients are consistent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test offline editing and conflict resolution
|
||||||
|
*/
|
||||||
|
export const offlineEditing: TestDefinition = {
|
||||||
|
name: "Offline editing and reconnection",
|
||||||
|
clients: ["client1", "client2"],
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "create-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "shared.md",
|
||||||
|
content: "Initial",
|
||||||
|
description: "Client 1 creates initial file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "disable-sync",
|
||||||
|
clientId: "client2",
|
||||||
|
description: "Client 2 goes offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "update-file",
|
||||||
|
clientId: "client1",
|
||||||
|
path: "shared.md",
|
||||||
|
content: "Updated by client 1",
|
||||||
|
description: "Client 1 updates while Client 2 offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
clientId: "client1",
|
||||||
|
description: "Client 1 syncs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "update-file",
|
||||||
|
clientId: "client2",
|
||||||
|
path: "shared.md",
|
||||||
|
content: "Updated by client 2 offline",
|
||||||
|
description: "Client 2 updates while offline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "enable-sync",
|
||||||
|
clientId: "client2",
|
||||||
|
description: "Client 2 comes back online"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "wait-for-sync",
|
||||||
|
description: "Wait for sync and conflict resolution"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-all-clients-consistent",
|
||||||
|
description: "Verify clients converged after reconnection"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All example tests
|
||||||
|
*/
|
||||||
|
export const exampleTests: TestDefinition[] = [
|
||||||
|
simpleSync,
|
||||||
|
concurrentEdits,
|
||||||
|
fileDeletion,
|
||||||
|
fileRename,
|
||||||
|
deferredOperations,
|
||||||
|
offlineEditing
|
||||||
|
];
|
||||||
4
frontend/test-client/src/deterministic/index.ts
Normal file
4
frontend/test-client/src/deterministic/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type * from "./events";
|
||||||
|
export * from "./test-runner";
|
||||||
|
export * from "./deterministic-client";
|
||||||
|
export * from "./example-tests";
|
||||||
263
frontend/test-client/src/deterministic/test-runner.ts
Normal file
263
frontend/test-client/src/deterministic/test-runner.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import type { SyncSettings } from "sync-client";
|
||||||
|
import { debugging, Logger } from "sync-client";
|
||||||
|
import type { TestDefinition, TestEvent } from "./events";
|
||||||
|
import { DeterministicClient } from "./deterministic-client";
|
||||||
|
import { sleep } from "../utils/sleep";
|
||||||
|
import { assert } from "../utils/assert";
|
||||||
|
|
||||||
|
export class DeterministicTestRunner {
|
||||||
|
private readonly clients = new Map<string, DeterministicClient>();
|
||||||
|
private readonly jitterScaleInSeconds = 0.1; // Small jitter for realism
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly vaultName: string,
|
||||||
|
private readonly remoteUri: string,
|
||||||
|
private readonly token: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a test definition
|
||||||
|
*/
|
||||||
|
public async runTest(testDef: TestDefinition): Promise<void> {
|
||||||
|
console.info(`\n${"=".repeat(60)}`);
|
||||||
|
console.info(`Running test: ${testDef.name}`);
|
||||||
|
console.info(`${"=".repeat(60)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize clients
|
||||||
|
await this.initializeClients(testDef.clients);
|
||||||
|
|
||||||
|
// Execute events in sequence
|
||||||
|
for (let i = 0; i < testDef.events.length; i++) {
|
||||||
|
const event = testDef.events[i];
|
||||||
|
await this.executeEvent(event, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`\n✓ Test passed: ${testDef.name}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\n✗ Test failed: ${testDef.name}`);
|
||||||
|
console.error(`Error: ${error}\n`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all clients for the test
|
||||||
|
*/
|
||||||
|
private async initializeClients(clientIds: string[]): Promise<void> {
|
||||||
|
console.info(`Initializing ${clientIds.length} clients...`);
|
||||||
|
|
||||||
|
for (const clientId of clientIds) {
|
||||||
|
const settings: Partial<SyncSettings> = {
|
||||||
|
isSyncEnabled: true,
|
||||||
|
token: this.token,
|
||||||
|
vaultName: this.vaultName,
|
||||||
|
syncConcurrency: 16,
|
||||||
|
remoteUri: this.remoteUri
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new DeterministicClient(clientId, settings);
|
||||||
|
await client.init(
|
||||||
|
debugging.slowFetchFactory(this.jitterScaleInSeconds),
|
||||||
|
debugging.slowWebSocketFactory(
|
||||||
|
this.jitterScaleInSeconds,
|
||||||
|
new Logger()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
const connectionCheck = await client
|
||||||
|
.getSyncClient()
|
||||||
|
.checkConnection();
|
||||||
|
assert(
|
||||||
|
connectionCheck.isSuccessful,
|
||||||
|
`Failed to connect client ${clientId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.clients.set(clientId, client);
|
||||||
|
console.info(` ✓ Initialized client: ${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single event
|
||||||
|
*/
|
||||||
|
private async executeEvent(event: TestEvent, index: number): Promise<void> {
|
||||||
|
const description = event.description ?? event.type;
|
||||||
|
console.info(`[${index}] ${description}`);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "create-file": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.createFile(
|
||||||
|
event.path,
|
||||||
|
event.content,
|
||||||
|
event.immediate ?? true
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "update-file": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.updateFile(
|
||||||
|
event.path,
|
||||||
|
event.content,
|
||||||
|
event.immediate ?? true
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete-file": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.deleteFile(event.path, event.immediate ?? true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "rename-file": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.renameFile(
|
||||||
|
event.oldPath,
|
||||||
|
event.newPath,
|
||||||
|
event.immediate ?? true
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "append-to-file": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.appendToFile(
|
||||||
|
event.path,
|
||||||
|
event.content,
|
||||||
|
event.immediate ?? true
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "flush": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.flush();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "wait-for-sync": {
|
||||||
|
if (event.clientId) {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.waitForSync();
|
||||||
|
} else {
|
||||||
|
// Wait for all clients
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(this.clients.values()).map(async (c) =>
|
||||||
|
c.waitForSync()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enable-sync": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.setSyncEnabled(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "disable-sync": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.setSyncEnabled(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sleep": {
|
||||||
|
await sleep(event.milliseconds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assert-file-exists": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.assertFileExists(event.path, event.shouldExist);
|
||||||
|
console.info(
|
||||||
|
` ✓ Assertion passed: ${event.path} ${event.shouldExist ? "exists" : "doesn't exist"}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assert-file-content": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.assertFileContent(
|
||||||
|
event.path,
|
||||||
|
event.expectedContent
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
` ✓ Assertion passed: ${event.path} has expected content`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assert-file-count": {
|
||||||
|
const client = this.getClient(event.clientId);
|
||||||
|
await client.assertFileCount(event.expectedCount);
|
||||||
|
console.info(
|
||||||
|
` ✓ Assertion passed: ${event.expectedCount} files`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assert-all-clients-consistent": {
|
||||||
|
const clientList = Array.from(this.clients.values());
|
||||||
|
for (let i = 0; i < clientList.length - 1; i++) {
|
||||||
|
await clientList[i].assertConsistentWith(clientList[i + 1]);
|
||||||
|
}
|
||||||
|
console.info(` ✓ Assertion passed: all clients consistent`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assert-clients-consistent": {
|
||||||
|
const clientList = event.clientIds.map((id) =>
|
||||||
|
this.getClient(id)
|
||||||
|
);
|
||||||
|
for (let i = 0; i < clientList.length - 1; i++) {
|
||||||
|
await clientList[i].assertConsistentWith(clientList[i + 1]);
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
` ✓ Assertion passed: clients ${event.clientIds.join(", ")} consistent`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// @ts-expect-error - exhaustive check
|
||||||
|
throw new Error(`Unknown event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a client by ID
|
||||||
|
*/
|
||||||
|
private getClient(clientId: string): DeterministicClient {
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client ${clientId} not found`);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all clients
|
||||||
|
*/
|
||||||
|
private async cleanup(): Promise<void> {
|
||||||
|
console.info("Cleaning up clients...");
|
||||||
|
for (const [id, client] of this.clients) {
|
||||||
|
try {
|
||||||
|
await client.destroy();
|
||||||
|
console.info(` ✓ Destroyed client: ${id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ✗ Failed to destroy client ${id}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clients.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
|
|
||||||
module.exports = {
|
const baseConfig = {
|
||||||
entry: "./src/cli.ts",
|
|
||||||
target: "node",
|
target: "node",
|
||||||
mode: "production",
|
mode: "production",
|
||||||
optimization: {
|
optimization: {
|
||||||
|
|
@ -19,12 +18,28 @@ module.exports = {
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [".ts", ".js"]
|
extensions: [".ts", ".js"]
|
||||||
},
|
},
|
||||||
output: {
|
|
||||||
globalObject: "this",
|
|
||||||
filename: "cli.js",
|
|
||||||
path: path.resolve(__dirname, "dist")
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
...baseConfig,
|
||||||
|
entry: "./src/cli.ts",
|
||||||
|
output: {
|
||||||
|
globalObject: "this",
|
||||||
|
filename: "cli.js",
|
||||||
|
path: path.resolve(__dirname, "dist")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseConfig,
|
||||||
|
entry: "./src/deterministic/cli.ts",
|
||||||
|
output: {
|
||||||
|
globalObject: "this",
|
||||||
|
filename: "deterministic/cli.js",
|
||||||
|
path: path.resolve(__dirname, "dist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue