From 9015c785988e1870fa655c58f90320d71d92aaf5 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 30 Nov 2025 15:25:20 +0000 Subject: [PATCH] . --- .../src/utils/data-structures/locks.ts | 3 + frontend/test-client/package.json | 4 +- frontend/test-client/src/deterministic/cli.ts | 68 ++++ .../src/deterministic/deterministic-client.ts | 241 ++++++++++++ .../test-client/src/deterministic/events.ts | 143 +++++++ .../src/deterministic/example-tests.ts | 350 ++++++++++++++++++ .../test-client/src/deterministic/index.ts | 4 + .../src/deterministic/test-runner.ts | 263 +++++++++++++ frontend/test-client/webpack.config.js | 29 +- 9 files changed, 1097 insertions(+), 8 deletions(-) create mode 100644 frontend/test-client/src/deterministic/cli.ts create mode 100644 frontend/test-client/src/deterministic/deterministic-client.ts create mode 100644 frontend/test-client/src/deterministic/events.ts create mode 100644 frontend/test-client/src/deterministic/example-tests.ts create mode 100644 frontend/test-client/src/deterministic/index.ts create mode 100644 frontend/test-client/src/deterministic/test-runner.ts diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index fccccf8c..e735063f 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -123,6 +123,9 @@ export class Locks { */ public unlock(key: T): void { if (!this.locked.has(key)) { + this.logger?.warn( + `Attempted to unlock key "${key}" which is not currently locked` + ); return; } diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index 2dd58734..fe1f81c6 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -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", diff --git a/frontend/test-client/src/deterministic/cli.ts b/frontend/test-client/src/deterministic/cli.ts new file mode 100644 index 00000000..cbfce2fa --- /dev/null +++ b/frontend/test-client/src/deterministic/cli.ts @@ -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 { + 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); + }); diff --git a/frontend/test-client/src/deterministic/deterministic-client.ts b/frontend/test-client/src/deterministic/deterministic-client.ts new file mode 100644 index 00000000..5cb92409 --- /dev/null +++ b/frontend/test-client/src/deterministic/deterministic-client.ts @@ -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)[] = []; + + public constructor( + public readonly clientId: string, + initialSettings: Partial + ) { + 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 { + const operation = async (): Promise => { + 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 { + const operation = async (): Promise => { + 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 { + const operation = async (): Promise => { + 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 { + const operation = async (): Promise => { + 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 { + const operation = async (): Promise => { + await this.rename(oldPath, newPath); + }; + + if (immediate) { + await operation(); + } else { + this.pendingOperations.push(operation); + } + } + + /** + * Flush all pending operations + */ + public async flush(): Promise { + const operations = [...this.pendingOperations]; + this.pendingOperations = []; + + for (const operation of operations) { + await operation(); + } + } + + /** + * Wait until all sync operations are complete + */ + public async waitForSync(): Promise { + await this.client.waitUntilFinished(); + } + + /** + * Enable or disable sync + */ + public async setSyncEnabled(enabled: boolean): Promise { + await this.client.setSetting("isSyncEnabled", enabled); + } + + /** + * Get file content as string + */ + public async getFileContent(path: RelativePath): Promise { + const content = await this.read(path); + return new TextDecoder().decode(content); + } + + /** + * Get number of files + */ + public async getFileCount(): Promise { + const files = await this.listFilesRecursively(); + return files.length; + } + + /** + * Assert file exists or doesn't exist + */ + public async assertFileExists( + path: RelativePath, + shouldExist: boolean + ): Promise { + 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 { + 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 { + 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 { + 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 { + await this.client.destroy(); + } +} diff --git a/frontend/test-client/src/deterministic/events.ts b/frontend/test-client/src/deterministic/events.ts new file mode 100644 index 00000000..cc17c56f --- /dev/null +++ b/frontend/test-client/src/deterministic/events.ts @@ -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[]; +} diff --git a/frontend/test-client/src/deterministic/example-tests.ts b/frontend/test-client/src/deterministic/example-tests.ts new file mode 100644 index 00000000..2c757a9c --- /dev/null +++ b/frontend/test-client/src/deterministic/example-tests.ts @@ -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 +]; diff --git a/frontend/test-client/src/deterministic/index.ts b/frontend/test-client/src/deterministic/index.ts new file mode 100644 index 00000000..55783659 --- /dev/null +++ b/frontend/test-client/src/deterministic/index.ts @@ -0,0 +1,4 @@ +export type * from "./events"; +export * from "./test-runner"; +export * from "./deterministic-client"; +export * from "./example-tests"; diff --git a/frontend/test-client/src/deterministic/test-runner.ts b/frontend/test-client/src/deterministic/test-runner.ts new file mode 100644 index 00000000..9187e19c --- /dev/null +++ b/frontend/test-client/src/deterministic/test-runner.ts @@ -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(); + 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 { + 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 { + console.info(`Initializing ${clientIds.length} clients...`); + + for (const clientId of clientIds) { + const settings: Partial = { + 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 { + 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 { + 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(); + } +} diff --git a/frontend/test-client/webpack.config.js b/frontend/test-client/webpack.config.js index b2324b9b..4ca92189 100644 --- a/frontend/test-client/webpack.config.js +++ b/frontend/test-client/webpack.config.js @@ -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") + } + } +];