.
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 {
|
||||
if (!this.locked.has(key)) {
|
||||
this.logger?.warn(
|
||||
`Attempted to unlock key "${key}" which is not currently locked`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
"scripts": {
|
||||
"dev": "webpack watch --mode development",
|
||||
"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": {
|
||||
"@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 webpack = require("webpack");
|
||||
|
||||
module.exports = {
|
||||
entry: "./src/cli.ts",
|
||||
const baseConfig = {
|
||||
target: "node",
|
||||
mode: "production",
|
||||
optimization: {
|
||||
|
|
@ -19,12 +18,28 @@ module.exports = {
|
|||
resolve: {
|
||||
extensions: [".ts", ".js"]
|
||||
},
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "cli.js",
|
||||
path: path.resolve(__dirname, "dist")
|
||||
},
|
||||
plugins: [
|
||||
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