Add deterministic tests and lint
This commit is contained in:
parent
ea5a123cb8
commit
16afe31e89
29 changed files with 1740 additions and 224 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -7,15 +7,19 @@ node_modules
|
||||||
# Frontend build folders
|
# Frontend build folders
|
||||||
frontend/*/dist
|
frontend/*/dist
|
||||||
|
|
||||||
sync-server/db.sqlite3*
|
|
||||||
sync-server/databases
|
|
||||||
|
|
||||||
# Rust build folders
|
# Rust build folders
|
||||||
sync-server/target
|
sync-server/target
|
||||||
sync-server/artifacts
|
sync-server/artifacts
|
||||||
sync-server/bindings/*.ts
|
sync-server/bindings/*.ts
|
||||||
|
|
||||||
|
# build folders
|
||||||
|
sync-server/db.sqlite3*
|
||||||
|
sync-server/databases
|
||||||
|
frontend/deterministic-tests/databases
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
*.sqlx
|
*.sqlx
|
||||||
|
|
||||||
target
|
target
|
||||||
|
|
||||||
|
.task
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -46,41 +46,40 @@ npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scripts
|
### Common Tasks
|
||||||
|
|
||||||
|
This project uses [Taskfile](https://taskfile.dev/) for task automation. Run `task --list` to see all available tasks.
|
||||||
|
|
||||||
#### Before pushing
|
#### Before pushing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/check.sh --fix
|
task check:fix
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Update HTTP API TS bindings
|
#### Update HTTP API TS bindings
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/update-api-types.sh
|
task update-api-types
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Publish new version
|
#### Publish new version
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/bump-version.sh patch
|
task release:bump -- patch
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run E2E tests
|
#### Run E2E tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/e2e.sh 8
|
task e2e -- 8
|
||||||
```
|
```
|
||||||
|
|
||||||
And to clean up the logs & database files, run `scripts/clean-up.sh`
|
And to clean up the logs & database files, run `task clean`
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
- [Sync server](./sync-server/README.md)
|
- [Sync server](./sync-server/README.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
a create that has been processed by the server but got lost on the way back will create a 2nd doc if it gets edited
|
a create that has been processed by the server but got lost on the way back will create a 2nd doc if it gets edited
|
||||||
|
|
||||||
|
remove force merge everywhere
|
||||||
|
|
|
||||||
283
frontend/deterministic-tests/README.md
Normal file
283
frontend/deterministic-tests/README.md
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
# Deterministic Testing Framework
|
||||||
|
|
||||||
|
A framework for defining and running deterministic tests for VaultLink sync operations. Unlike the fuzz testing approach, these tests execute exact sequences of operations to verify specific conflict resolution scenarios.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The deterministic testing framework allows you to:
|
||||||
|
|
||||||
|
- Define exact sequences of client operations in TypeScript
|
||||||
|
- Control both client and server processes (pause/resume)
|
||||||
|
- Test specific conflict scenarios (write/write, rename/create, etc.)
|
||||||
|
- Verify that the system resolves conflicts consistently
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Test Definition (TypeScript) │
|
||||||
|
│ - Declare steps sequentially │
|
||||||
|
│ - Specify client operations │
|
||||||
|
│ - Add assertions │
|
||||||
|
└──────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Test Runner │
|
||||||
|
│ - Initializes clients │
|
||||||
|
│ - Executes steps in order │
|
||||||
|
│ - Manages server lifecycle │
|
||||||
|
└──────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─→ DeterministicAgent (per client)
|
||||||
|
│ └─→ SyncClient
|
||||||
|
│
|
||||||
|
└─→ ServerControl
|
||||||
|
└─→ sync_server process
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Definition Format
|
||||||
|
|
||||||
|
Tests are defined using the `TestDefinition` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TestDefinition {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
clients: number;
|
||||||
|
steps: TestStep[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Steps
|
||||||
|
|
||||||
|
#### File Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "hello" }
|
||||||
|
{ type: "update", client: 0, path: "file.md", content: "world" }
|
||||||
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }
|
||||||
|
{ type: "delete", client: 0, path: "file.md" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sync Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "sync", client: 0 } // Wait for specific client
|
||||||
|
{ type: "sync" } // Wait for all clients
|
||||||
|
{ type: "barrier" } // Wait for all pending ops
|
||||||
|
{ type: "disable-sync", client: 0 }
|
||||||
|
{ type: "enable-sync", client: 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Server Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "pause-server" } // Pause server process
|
||||||
|
{ type: "resume-server" } // Resume server process
|
||||||
|
{ type: "wait", duration: 500 } // Wait N milliseconds
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Assertions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "assert-content", client: 0, path: "file.md", content: "hello" }
|
||||||
|
{ type: "assert-exists", client: 0, path: "file.md" }
|
||||||
|
{ type: "assert-not-exists", client: 0, path: "file.md" }
|
||||||
|
{ type: "assert-consistent" } // All clients have same state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Tests
|
||||||
|
|
||||||
|
### Write/Write Conflict
|
||||||
|
|
||||||
|
Two clients create the same file with different content:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const writeWriteConflictTest: TestDefinition = {
|
||||||
|
name: "Write/Write Conflict",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
|
{ type: "create", client: 1, path: "A.md", content: "world" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "wait", duration: 500 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename/Create Conflict
|
||||||
|
|
||||||
|
Client 1 renames A→B while Client 0 creates B:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const renameCreateConflictTest: TestDefinition = {
|
||||||
|
name: "Rename-Create Conflict",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "wait", duration: 500 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From frontend/deterministic-tests
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --test write-write-conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Available Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use custom server binary
|
||||||
|
npm run test -- --server /path/to/sync_server
|
||||||
|
|
||||||
|
# Use custom config
|
||||||
|
npm run test -- --config /path/to/config.yml
|
||||||
|
|
||||||
|
# Don't manage server (assume it's already running)
|
||||||
|
npm run test -- --no-manage-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating New Tests
|
||||||
|
|
||||||
|
1. Create a new test file in `src/tests/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// my-test.test.ts
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const myTest: TestDefinition = {
|
||||||
|
name: "My Test",
|
||||||
|
description: "What this test verifies",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
// Your test steps here
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register the test in `src/cli.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { myTest } from "./tests/my-test.test";
|
||||||
|
|
||||||
|
const TESTS: Record<string, TestDefinition> = {
|
||||||
|
// ... existing tests
|
||||||
|
"my-test": myTest
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --test my-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### Synchronization Points
|
||||||
|
|
||||||
|
Use explicit sync barriers to ensure operations complete:
|
||||||
|
|
||||||
|
- `{ type: "sync", client: 0 }` - Wait for client 0 to finish pending ops
|
||||||
|
- `{ type: "barrier" }` - Wait for all clients to finish
|
||||||
|
- `{ type: "wait", duration: 500 }` - Wait for propagation
|
||||||
|
|
||||||
|
### Offline Testing
|
||||||
|
|
||||||
|
Disable sync to simulate offline edits:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "offline edit" },
|
||||||
|
{ type: "enable-sync", client: 0 }, // Sync when back online
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Control
|
||||||
|
|
||||||
|
Pause the server to test reconnection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "while paused" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
Always end tests with consistency checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "assert-consistent";
|
||||||
|
} // Verify all clients converged
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server Won't Start
|
||||||
|
|
||||||
|
- Ensure server is built: `cd sync-server && cargo build`
|
||||||
|
- Check config file exists: `sync-server/config-e2e.yml`
|
||||||
|
- Verify port 3000 is available
|
||||||
|
|
||||||
|
### Test Hangs
|
||||||
|
|
||||||
|
- Increase wait durations for slow systems
|
||||||
|
- Add more `{ type: "barrier" }` steps
|
||||||
|
- Check server logs for errors
|
||||||
|
|
||||||
|
### Assertion Failures
|
||||||
|
|
||||||
|
- Add `{ type: "wait", duration: 1000 }` before assertions
|
||||||
|
- Check if conflict resolution is working as expected
|
||||||
|
- Review test steps for logic errors
|
||||||
|
|
||||||
|
## Comparison to Fuzz Tests
|
||||||
|
|
||||||
|
| Aspect | Fuzz Tests | Deterministic Tests |
|
||||||
|
| --------------- | --------------- | ------------------------- |
|
||||||
|
| Operations | Random | Explicit sequence |
|
||||||
|
| Reproducibility | Difficult | Perfect |
|
||||||
|
| Coverage | Broad | Targeted |
|
||||||
|
| Debugging | Hard | Easy |
|
||||||
|
| Use Case | Find edge cases | Verify specific scenarios |
|
||||||
|
|
||||||
|
Use both approaches:
|
||||||
|
|
||||||
|
- Fuzz tests for discovering unexpected issues
|
||||||
|
- Deterministic tests for verifying specific fixes
|
||||||
24
frontend/deterministic-tests/package.json
Normal file
24
frontend/deterministic-tests/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "deterministic-tests",
|
||||||
|
"version": "0.14.0",
|
||||||
|
"private": true,
|
||||||
|
"bin": {
|
||||||
|
"deterministic-tests": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "webpack watch --mode development",
|
||||||
|
"build": "webpack --mode production",
|
||||||
|
"test": "npm run build && node dist/cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"sync-client": "file:../sync-client",
|
||||||
|
"ts-loader": "^9.5.4",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"webpack": "^5.103.0",
|
||||||
|
"webpack-cli": "^6.0.1",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
233
frontend/deterministic-tests/src/cli.ts
Normal file
233
frontend/deterministic-tests/src/cli.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { TestRunner } from "./test-runner";
|
||||||
|
import { ServerControl } from "./server-control";
|
||||||
|
import type { TestDefinition } from "./test-definition";
|
||||||
|
import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
|
||||||
|
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
|
||||||
|
// Global error handlers to catch unhandled errors
|
||||||
|
process.on("unhandledRejection", (reason, promise) => {
|
||||||
|
console.error("Unhandled Rejection at:", promise);
|
||||||
|
console.error("Reason:", reason);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
console.error("Uncaught Exception:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Available tests - using Partial to allow undefined lookup
|
||||||
|
const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
|
"write-write-conflict": writeWriteConflictTest,
|
||||||
|
"rename-create-conflict": renameCreateConflictTest
|
||||||
|
};
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
Deterministic Test Runner for VaultLink
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npm run test [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--test <name> Run specific test (or "all")
|
||||||
|
--list List available tests
|
||||||
|
--server <path> Path to sync_server binary (default: auto-detect)
|
||||||
|
--config <path> Path to config file (default: config-e2e.yml)
|
||||||
|
--no-manage-server Don't start/stop server (assume it's running)
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm run test
|
||||||
|
npm run test -- --test write-write-conflict
|
||||||
|
npm run test -- --test all
|
||||||
|
npm run test -- --list
|
||||||
|
npm run test -- --no-manage-server --test rename-create-conflict
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
let testName: string | undefined = undefined;
|
||||||
|
let serverPath: string | undefined = undefined;
|
||||||
|
let configPath: string | undefined = undefined;
|
||||||
|
let manageServer = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--test" && i + 1 < args.length) {
|
||||||
|
testName = args[++i];
|
||||||
|
} else if (arg === "--server" && i + 1 < args.length) {
|
||||||
|
serverPath = args[++i];
|
||||||
|
} else if (arg === "--config" && i + 1 < args.length) {
|
||||||
|
configPath = args[++i];
|
||||||
|
} else if (arg === "--no-manage-server") {
|
||||||
|
manageServer = false;
|
||||||
|
} else if (arg === "--list") {
|
||||||
|
console.log("\nAvailable tests:");
|
||||||
|
for (const [name, test] of Object.entries(TESTS)) {
|
||||||
|
if (test !== undefined) {
|
||||||
|
console.log(` ${name}: ${test.description ?? test.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} else if (arg === "--help" || arg === "-h") {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
if (serverPath === undefined) {
|
||||||
|
// Try to find project root from current working directory
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let projectRoot = cwd;
|
||||||
|
|
||||||
|
// If we're in frontend/deterministic-tests, go up two levels
|
||||||
|
if (
|
||||||
|
cwd.endsWith("frontend/deterministic-tests") ||
|
||||||
|
cwd.endsWith("frontend\\deterministic-tests")
|
||||||
|
) {
|
||||||
|
projectRoot = path.resolve(cwd, "../..");
|
||||||
|
}
|
||||||
|
// If we're in frontend, go up one level
|
||||||
|
else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) {
|
||||||
|
projectRoot = path.resolve(cwd, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
serverPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"sync-server/target/debug/sync_server"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if server binary exists
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
console.error(`Server binary not found at: ${serverPath}`);
|
||||||
|
console.error(
|
||||||
|
"Please build the server first: cd sync-server && cargo build"
|
||||||
|
);
|
||||||
|
console.error(`Current working directory: ${cwd}`);
|
||||||
|
console.error(`Project root detected as: ${projectRoot}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configPath === undefined) {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let projectRoot = cwd;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cwd.endsWith("frontend/deterministic-tests") ||
|
||||||
|
cwd.endsWith("frontend\\deterministic-tests")
|
||||||
|
) {
|
||||||
|
projectRoot = path.resolve(cwd, "../..");
|
||||||
|
} else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) {
|
||||||
|
projectRoot = path.resolve(cwd, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath = path.join(projectRoot, "sync-server/config-e2e.yml");
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
console.error(`Config file not found at: ${configPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which tests to run
|
||||||
|
const testsToRun: TestDefinition[] = [];
|
||||||
|
|
||||||
|
// Collect all defined tests
|
||||||
|
const allTests: TestDefinition[] = [];
|
||||||
|
for (const test of Object.values(TESTS)) {
|
||||||
|
if (test !== undefined) {
|
||||||
|
allTests.push(test);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testName !== undefined) {
|
||||||
|
if (testName === "all") {
|
||||||
|
testsToRun.push(...allTests);
|
||||||
|
} else {
|
||||||
|
const test = TESTS[testName];
|
||||||
|
if (test === undefined) {
|
||||||
|
console.error(`Unknown test: ${testName}`);
|
||||||
|
console.error(
|
||||||
|
`Available tests: ${Object.keys(TESTS).join(", ")}, all`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
testsToRun.push(test);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: run all tests
|
||||||
|
testsToRun.push(...allTests);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDeterministic Test Suite`);
|
||||||
|
console.log("=".repeat(80));
|
||||||
|
console.log(`Server: ${serverPath}`);
|
||||||
|
console.log(`Config: ${configPath}`);
|
||||||
|
console.log(`Manage server: ${manageServer}`);
|
||||||
|
console.log(`Tests to run: ${testsToRun.length}`);
|
||||||
|
console.log(`${"=".repeat(80)}\n`);
|
||||||
|
|
||||||
|
// Initialize server control
|
||||||
|
const serverControl = new ServerControl(serverPath, configPath);
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start server if we're managing it
|
||||||
|
if (manageServer) {
|
||||||
|
await serverControl.start();
|
||||||
|
} else {
|
||||||
|
console.log("Assuming server is already running...");
|
||||||
|
await serverControl.waitForReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
for (const test of testsToRun) {
|
||||||
|
const runner = new TestRunner(serverControl);
|
||||||
|
const result = await runner.runTest(test);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
allPassed = false;
|
||||||
|
console.error(`\n✗ FAILED: ${test.name}`);
|
||||||
|
console.error(`Error: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n✓ PASSED: ${test.name} (${result.duration}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between tests
|
||||||
|
if (testsToRun.indexOf(test) < testsToRun.length - 1) {
|
||||||
|
console.log("\nWaiting 2s before next test...\n");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Stop server if we're managing it
|
||||||
|
if (manageServer) {
|
||||||
|
await serverControl.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${"=".repeat(80)}`);
|
||||||
|
if (allPassed) {
|
||||||
|
console.log("✓ All tests passed!");
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log("✗ Some tests failed");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
console.error("Unexpected error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
267
frontend/deterministic-tests/src/deterministic-agent.ts
Normal file
267
frontend/deterministic-tests/src/deterministic-agent.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import type { StoredDatabase, TextWithCursors } from "sync-client";
|
||||||
|
import type {
|
||||||
|
RelativePath,
|
||||||
|
FileSystemOperations,
|
||||||
|
SyncSettings
|
||||||
|
} from "sync-client";
|
||||||
|
import { SyncClient } from "sync-client";
|
||||||
|
import { assert } from "./utils/assert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeterministicAgent - A test agent that properly awaits all sync operations.
|
||||||
|
*
|
||||||
|
* Unlike MockClient which fires-and-forgets sync operations, this class
|
||||||
|
* ensures each operation is fully registered with SyncClient before returning.
|
||||||
|
*/
|
||||||
|
export class DeterministicAgent implements FileSystemOperations {
|
||||||
|
public readonly clientId: number;
|
||||||
|
private readonly logger: (msg: string) => void;
|
||||||
|
private readonly localFiles = new Map<string, Uint8Array>();
|
||||||
|
private client!: SyncClient;
|
||||||
|
private data: Partial<{
|
||||||
|
settings: Partial<SyncSettings>;
|
||||||
|
database: Partial<StoredDatabase>;
|
||||||
|
}> = {};
|
||||||
|
// Track sync state locally to avoid calling sync methods when disabled
|
||||||
|
private isSyncEnabled = true;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
clientId: number,
|
||||||
|
initialSettings: Partial<SyncSettings>,
|
||||||
|
logger: (msg: string) => void
|
||||||
|
) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.logger = logger;
|
||||||
|
this.data.settings = initialSettings;
|
||||||
|
this.isSyncEnabled = initialSettings.isSyncEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init(
|
||||||
|
fetchImplementation: typeof globalThis.fetch,
|
||||||
|
webSocketImplementation: typeof globalThis.WebSocket
|
||||||
|
): Promise<void> {
|
||||||
|
this.client = await SyncClient.create({
|
||||||
|
fs: this,
|
||||||
|
persistence: {
|
||||||
|
load: async () => this.data,
|
||||||
|
save: async (data) => void (this.data = data)
|
||||||
|
},
|
||||||
|
fetch: fetchImplementation,
|
||||||
|
webSocket: webSocketImplementation
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.start();
|
||||||
|
|
||||||
|
// Verify connection is working
|
||||||
|
const connectionCheck = await this.client.checkConnection();
|
||||||
|
assert(
|
||||||
|
connectionCheck.isSuccessful,
|
||||||
|
`Client ${this.clientId} connection check failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSystemOperations implementation
|
||||||
|
public async listFilesRecursively(
|
||||||
|
_root?: RelativePath
|
||||||
|
): Promise<RelativePath[]> {
|
||||||
|
return Array.from(this.localFiles.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
|
const file = this.localFiles.get(path);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFileSize(path: RelativePath): Promise<number> {
|
||||||
|
return (await this.read(path)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exists(path: RelativePath): Promise<boolean> {
|
||||||
|
return this.localFiles.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||||
|
// This is called by SyncClient to write files received from the server.
|
||||||
|
// Do NOT call sync methods here - that would create a feedback loop.
|
||||||
|
this.localFiles.set(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
|
// Virtual FS doesn't need directories
|
||||||
|
}
|
||||||
|
|
||||||
|
public async atomicUpdateText(
|
||||||
|
path: RelativePath,
|
||||||
|
updater: (currentContent: TextWithCursors) => TextWithCursors
|
||||||
|
): Promise<string> {
|
||||||
|
// This is called by SyncClient (via FileOperations.write) during merge handling.
|
||||||
|
// Do NOT call sync methods here - that would create a deadlock.
|
||||||
|
const file = this.localFiles.get(path);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
const currentContent = new TextDecoder().decode(file);
|
||||||
|
const newContent = updater({ text: currentContent, cursors: [] }).text;
|
||||||
|
this.localFiles.set(path, new TextEncoder().encode(newContent));
|
||||||
|
return newContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
|
// This is called by SyncClient to delete files.
|
||||||
|
// Do NOT call sync methods here - that would create a feedback loop.
|
||||||
|
this.localFiles.delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rename(
|
||||||
|
oldPath: RelativePath,
|
||||||
|
newPath: RelativePath
|
||||||
|
): Promise<void> {
|
||||||
|
// This is called by SyncClient to rename files.
|
||||||
|
// Do NOT call sync methods here - that would create a feedback loop.
|
||||||
|
const file = this.localFiles.get(oldPath);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${oldPath} does not exist`);
|
||||||
|
}
|
||||||
|
this.localFiles.set(newPath, file);
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
this.localFiles.delete(oldPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test operations
|
||||||
|
public async createFile(path: string, content: string): Promise<void> {
|
||||||
|
this.log(`Creating file ${path} with content: ${content}`);
|
||||||
|
if (this.localFiles.has(path)) {
|
||||||
|
throw new Error(`File ${path} already exists`);
|
||||||
|
}
|
||||||
|
const contentBytes = new TextEncoder().encode(content);
|
||||||
|
this.localFiles.set(path, contentBytes);
|
||||||
|
|
||||||
|
// Only sync if enabled - otherwise scheduleSyncForOfflineChanges will pick it up
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyCreatedFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateFile(path: string, content: string): Promise<void> {
|
||||||
|
this.log(`Updating file ${path} with content: ${content}`);
|
||||||
|
const contentBytes = new TextEncoder().encode(content);
|
||||||
|
this.localFiles.set(path, contentBytes);
|
||||||
|
|
||||||
|
// Only sync if enabled
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async renameFile(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
this.log(`Renaming file ${oldPath} to ${newPath}`);
|
||||||
|
// Update local state
|
||||||
|
const file = this.localFiles.get(oldPath);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${oldPath} does not exist`);
|
||||||
|
}
|
||||||
|
this.localFiles.set(newPath, file);
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
this.localFiles.delete(oldPath);
|
||||||
|
}
|
||||||
|
// Only sync if enabled
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyUpdatedFile({
|
||||||
|
oldPath,
|
||||||
|
relativePath: newPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteFile(path: string): Promise<void> {
|
||||||
|
this.log(`Deleting file ${path}`);
|
||||||
|
// Update local state
|
||||||
|
this.localFiles.delete(path);
|
||||||
|
// Only sync if enabled
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyDeletedFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForSync(): Promise<void> {
|
||||||
|
this.log("Waiting for sync to complete...");
|
||||||
|
await this.client.waitUntilFinished();
|
||||||
|
this.log("Sync complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disableSync(): Promise<void> {
|
||||||
|
this.log("Disabling sync");
|
||||||
|
this.isSyncEnabled = false;
|
||||||
|
await this.client.setSetting("isSyncEnabled", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enableSync(): Promise<void> {
|
||||||
|
this.log("Enabling sync");
|
||||||
|
this.isSyncEnabled = true;
|
||||||
|
await this.client.setSetting("isSyncEnabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertContent(
|
||||||
|
path: string,
|
||||||
|
expectedContent: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.log(`Asserting content of ${path} equals "${expectedContent}"`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
exists,
|
||||||
|
`File ${path} does not exist on client ${this.clientId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualBytes = await this.read(path);
|
||||||
|
const actualContent = new TextDecoder().decode(actualBytes);
|
||||||
|
assert(
|
||||||
|
actualContent === expectedContent,
|
||||||
|
`Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"`
|
||||||
|
);
|
||||||
|
this.log(`✓ Content assertion passed for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertExists(path: string): Promise<void> {
|
||||||
|
this.log(`Asserting ${path} exists`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
exists,
|
||||||
|
`File ${path} does not exist on client ${this.clientId}`
|
||||||
|
);
|
||||||
|
this.log(`✓ File ${path} exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertNotExists(path: string): Promise<void> {
|
||||||
|
this.log(`Asserting ${path} does not exist`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
!exists,
|
||||||
|
`File ${path} exists on client ${this.clientId} but should not`
|
||||||
|
);
|
||||||
|
this.log(`✓ File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFiles(): Promise<RelativePath[]> {
|
||||||
|
return this.listFilesRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFileContent(path: string): Promise<string> {
|
||||||
|
const bytes = await this.read(path);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanup(): Promise<void> {
|
||||||
|
this.log("Cleaning up...");
|
||||||
|
await this.client.waitUntilFinished();
|
||||||
|
await this.client.destroy();
|
||||||
|
this.log("Cleanup complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string): void {
|
||||||
|
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
frontend/deterministic-tests/src/server-control.ts
Normal file
148
frontend/deterministic-tests/src/server-control.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { sleep } from "./utils/sleep";
|
||||||
|
|
||||||
|
export class ServerControl {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private readonly serverPath: string;
|
||||||
|
private readonly configPath: string;
|
||||||
|
|
||||||
|
public constructor(serverPath: string, configPath: string) {
|
||||||
|
this.serverPath = serverPath;
|
||||||
|
this.configPath = configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.process !== null) {
|
||||||
|
throw new Error("Server is already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting server: ${this.serverPath} ${this.configPath}`);
|
||||||
|
|
||||||
|
let startupError: string | null = null;
|
||||||
|
|
||||||
|
this.process = spawn(this.serverPath, [this.configPath], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout?.on("data", (data: Buffer) => {
|
||||||
|
console.log(`[SERVER] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const msg = data.toString().trim();
|
||||||
|
console.error(`[SERVER ERROR] ${msg}`);
|
||||||
|
// Capture startup errors
|
||||||
|
if (msg.includes("Failed to") || msg.includes("Error")) {
|
||||||
|
startupError = msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("error", (err) => {
|
||||||
|
console.error("[SERVER] Process error:", err);
|
||||||
|
startupError = err.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("exit", (code, signal) => {
|
||||||
|
console.log(`[SERVER] Exited with code ${code}, signal ${signal}`);
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give the process a moment to fail if it's going to
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
// Check if process died during startup (exit handler sets this.process to null)
|
||||||
|
this.checkProcessAlive(startupError, "startup");
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
await this.waitForReady();
|
||||||
|
|
||||||
|
// Final check that our process is still the one running
|
||||||
|
this.checkProcessAlive(startupError, "after startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForReady(maxAttempts = 30): Promise<void> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"http://localhost:3000/vaults/test/ping"
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
console.log("[SERVER] Ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error("Server failed to start within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(): void {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
throw new Error("Server is not running");
|
||||||
|
}
|
||||||
|
console.log("[SERVER] Pausing...");
|
||||||
|
process.kill(this.process.pid, "SIGSTOP");
|
||||||
|
}
|
||||||
|
|
||||||
|
public resume(): void {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
throw new Error("Server is not running");
|
||||||
|
}
|
||||||
|
console.log("[SERVER] Resuming...");
|
||||||
|
process.kill(this.process.pid, "SIGCONT");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[SERVER] Stopping...");
|
||||||
|
const { pid } = this.process;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.process === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.process.on("exit", () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try graceful shutdown first
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
|
||||||
|
// Force kill after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.process?.pid !== undefined) {
|
||||||
|
process.kill(this.process.pid, "SIGKILL");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRunning(): boolean {
|
||||||
|
return this.process?.pid !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkProcessAlive(
|
||||||
|
startupError: string | null,
|
||||||
|
phase: string
|
||||||
|
): void {
|
||||||
|
const proc = this.process;
|
||||||
|
if (proc === null) {
|
||||||
|
throw new Error(
|
||||||
|
`Server process died during ${phase}: ${startupError ?? "unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (proc.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`Server process exited during ${phase}: ${startupError ?? "unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/deterministic-tests/src/test-definition.ts
Normal file
35
frontend/deterministic-tests/src/test-definition.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Deterministic test framework for VaultLink sync testing.
|
||||||
|
* Allows defining exact sequences of operations to test specific scenarios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TestStep =
|
||||||
|
| { type: "create"; client: number; path: string; content: string }
|
||||||
|
| { type: "update"; client: number; path: string; content: string }
|
||||||
|
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
||||||
|
| { type: "delete"; client: number; path: string }
|
||||||
|
| { type: "sync"; client?: number } // wait for sync (specific client or all if undefined)
|
||||||
|
| { type: "disable-sync"; client: number }
|
||||||
|
| { type: "enable-sync"; client: number }
|
||||||
|
| { type: "wait"; duration: number } // wait N milliseconds
|
||||||
|
| { type: "pause-server" }
|
||||||
|
| { type: "resume-server" }
|
||||||
|
| { type: "barrier" } // wait for all clients to finish pending operations
|
||||||
|
| { type: "assert-content"; client: number; path: string; content: string }
|
||||||
|
| { type: "assert-exists"; client: number; path: string }
|
||||||
|
| { type: "assert-not-exists"; client: number; path: string }
|
||||||
|
| { type: "assert-consistent" }; // all clients have same files and content
|
||||||
|
|
||||||
|
export interface TestDefinition {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
clients: number;
|
||||||
|
steps: TestStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
stepsFailed?: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
292
frontend/deterministic-tests/src/test-runner.ts
Normal file
292
frontend/deterministic-tests/src/test-runner.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
||||||
|
import { DeterministicAgent } from "./deterministic-agent";
|
||||||
|
import type { ServerControl } from "./server-control";
|
||||||
|
import type { SyncSettings } from "sync-client";
|
||||||
|
import { utils } from "sync-client";
|
||||||
|
import { sleep } from "./utils/sleep";
|
||||||
|
import { assert } from "./utils/assert";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export class TestRunner {
|
||||||
|
private agents: DeterministicAgent[] = [];
|
||||||
|
private readonly serverControl: ServerControl;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly remoteUri: string;
|
||||||
|
private readonly logBuffer: string[] = [];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
serverControl: ServerControl,
|
||||||
|
options: {
|
||||||
|
token?: string;
|
||||||
|
remoteUri?: string;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
this.serverControl = serverControl;
|
||||||
|
this.token = options.token ?? "test-token-change-me ";
|
||||||
|
this.remoteUri = options.remoteUri ?? "http://localhost:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runTest(test: TestDefinition): Promise<TestResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
this.log(`\n${"=".repeat(80)}`);
|
||||||
|
this.log(`Running test: ${test.name}`);
|
||||||
|
if (test.description !== undefined && test.description !== "") {
|
||||||
|
this.log(`Description: ${test.description}`);
|
||||||
|
}
|
||||||
|
this.log(`Clients: ${test.clients}`);
|
||||||
|
this.log(`Steps: ${test.steps.length}`);
|
||||||
|
this.log("=".repeat(80));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize agents
|
||||||
|
await this.initializeAgents(test.clients);
|
||||||
|
|
||||||
|
// Execute steps
|
||||||
|
for (let i = 0; i < test.steps.length; i++) {
|
||||||
|
const step = test.steps[i];
|
||||||
|
this.log(
|
||||||
|
`\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
|
||||||
|
);
|
||||||
|
await this.executeStep(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await this.cleanup();
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.log(`\n✓ Test passed: ${test.name} (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
this.log(`\n✗ Test failed: ${test.name}`);
|
||||||
|
this.log(`Error: ${errorMessage}`);
|
||||||
|
|
||||||
|
await this.cleanup();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLog(): string {
|
||||||
|
return this.logBuffer.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const logLine = `[${timestamp}] ${message}`;
|
||||||
|
console.log(logLine);
|
||||||
|
this.logBuffer.push(logLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeAgents(count: number): Promise<void> {
|
||||||
|
// Use unique vault name for each test run to avoid data interference
|
||||||
|
const vaultName = `test-${randomUUID()}`;
|
||||||
|
this.log(`\nInitializing ${count} agents with vault: ${vaultName}`);
|
||||||
|
|
||||||
|
const settings: Partial<SyncSettings> = {
|
||||||
|
// Start with sync disabled to avoid scheduleSyncForOfflineChanges running
|
||||||
|
// before we've created our test files. Tests must explicitly enable sync.
|
||||||
|
isSyncEnabled: false,
|
||||||
|
token: this.token,
|
||||||
|
vaultName,
|
||||||
|
syncConcurrency: 1,
|
||||||
|
remoteUri: this.remoteUri
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const agent = new DeterministicAgent(i, settings, (msg) => {
|
||||||
|
this.log(msg);
|
||||||
|
});
|
||||||
|
// WebSocket from 'ws' package needs type assertion for browser WebSocket interface
|
||||||
|
|
||||||
|
await agent.init(
|
||||||
|
fetch,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
WebSocket as unknown as typeof globalThis.WebSocket
|
||||||
|
);
|
||||||
|
this.agents.push(agent);
|
||||||
|
this.log(`Initialized client ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for WebSocket connections to fully establish
|
||||||
|
await sleep(100);
|
||||||
|
this.log("All agents initialized and connected");
|
||||||
|
// Note: Sync is disabled on all agents. Tests must explicitly enable sync.
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeStep(step: TestStep): Promise<void> {
|
||||||
|
switch (step.type) {
|
||||||
|
case "create":
|
||||||
|
await this.agents[step.client].createFile(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "update":
|
||||||
|
await this.agents[step.client].updateFile(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rename":
|
||||||
|
await this.agents[step.client].renameFile(
|
||||||
|
step.oldPath,
|
||||||
|
step.newPath
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
await this.agents[step.client].deleteFile(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "sync":
|
||||||
|
if (step.client !== undefined) {
|
||||||
|
await this.agents[step.client].waitForSync();
|
||||||
|
} else {
|
||||||
|
// Wait for all clients
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.waitForSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disable-sync":
|
||||||
|
await this.agents[step.client].disableSync();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "enable-sync":
|
||||||
|
await this.agents[step.client].enableSync();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wait":
|
||||||
|
this.log(`Waiting ${step.duration}ms...`);
|
||||||
|
await sleep(step.duration);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pause-server":
|
||||||
|
this.serverControl.pause();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "resume-server":
|
||||||
|
this.serverControl.resume();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "barrier":
|
||||||
|
this.log(
|
||||||
|
"Barrier: waiting for all clients to finish pending operations..."
|
||||||
|
);
|
||||||
|
// First, wait for all local pending operations to complete
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.waitForSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for network propagation
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Then sync again to ensure all clients have received updates from others
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.waitForSync();
|
||||||
|
}
|
||||||
|
this.log("Barrier complete");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-content":
|
||||||
|
await this.agents[step.client].assertContent(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-exists":
|
||||||
|
await this.agents[step.client].assertExists(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-not-exists":
|
||||||
|
await this.agents[step.client].assertNotExists(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-consistent":
|
||||||
|
await this.assertConsistent();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const unknownStep = step as { type: string };
|
||||||
|
throw new Error(`Unknown step type: ${unknownStep.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertConsistent(): Promise<void> {
|
||||||
|
this.log("Asserting all clients are consistent...");
|
||||||
|
|
||||||
|
if (this.agents.length < 2) {
|
||||||
|
this.log("Only one client, skipping consistency check");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [referenceAgent] = this.agents;
|
||||||
|
const referenceFiles = (await referenceAgent.getFiles()).sort();
|
||||||
|
|
||||||
|
this.log(
|
||||||
|
`Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 1; i < this.agents.length; i++) {
|
||||||
|
const agent = this.agents[i];
|
||||||
|
const files = (await agent.getFiles()).sort();
|
||||||
|
|
||||||
|
this.log(
|
||||||
|
`Client ${i} has ${files.length} files: ${files.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check file lists match
|
||||||
|
assert(
|
||||||
|
files.length === referenceFiles.length,
|
||||||
|
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < files.length; j++) {
|
||||||
|
assert(
|
||||||
|
files[j] === referenceFiles[j],
|
||||||
|
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file contents match
|
||||||
|
for (const file of referenceFiles) {
|
||||||
|
const referenceContent =
|
||||||
|
await referenceAgent.getFileContent(file);
|
||||||
|
const agentContent = await agent.getFileContent(file);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
referenceContent === agentContent,
|
||||||
|
`Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log("✓ All clients are consistent");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanup(): Promise<void> {
|
||||||
|
this.log("\nCleaning up agents...");
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
this.agents = [];
|
||||||
|
this.log("Cleanup complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename-Create Conflict Test
|
||||||
|
*
|
||||||
|
* Scenario:
|
||||||
|
* - Client 0 creates file A with content "hi" and syncs it
|
||||||
|
* - Client 1 syncs (now has A with "hi")
|
||||||
|
* - Client 0 disables sync (disconnects WebSocket)
|
||||||
|
* - Client 1 renames A to B and syncs
|
||||||
|
* - Client 0 (offline, unaware of the rename) creates file B with content "hi"
|
||||||
|
* - Client 0 enables sync again
|
||||||
|
* - Both clients sync
|
||||||
|
*
|
||||||
|
* Expected behavior:
|
||||||
|
* - The system must resolve the conflict deterministically
|
||||||
|
* - Client 0's create of B conflicts with Client 1's rename of A to B
|
||||||
|
* - Possible resolutions:
|
||||||
|
* 1. One file wins (B contains one version)
|
||||||
|
* 2. Files are merged/renamed to avoid collision
|
||||||
|
* 3. One operation is rejected
|
||||||
|
* - Both clients must converge to a consistent state
|
||||||
|
*/
|
||||||
|
export const renameCreateConflictTest: TestDefinition = {
|
||||||
|
name: "Rename-Create Conflict",
|
||||||
|
description:
|
||||||
|
"Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " +
|
||||||
|
"The system must resolve the conflict deterministically.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
// Enable sync on all clients first (agents start with sync disabled)
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
// Client 0 creates file A with "hi" and syncs
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Client 1 syncs to get file A
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||||
|
{ type: "assert-content", client: 1, path: "A.md", content: "hi" },
|
||||||
|
|
||||||
|
// IMPORTANT: Disable sync on Client 0 BEFORE Client 1 renames
|
||||||
|
// This ensures Client 0 doesn't receive the rename notification via WebSocket
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
||||||
|
// Client 1 renames A to B and syncs
|
||||||
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
// Client 0 creates B (without knowing about the rename, since sync is disabled)
|
||||||
|
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||||
|
|
||||||
|
// Now enable sync on Client 0 and let conflict resolution happen
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "barrier" }, // Wait for conflict resolution
|
||||||
|
|
||||||
|
// Give system time to propagate
|
||||||
|
{ type: "wait", duration: 500 },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Verify both clients converge to the same state
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write/Write Conflict Test
|
||||||
|
*
|
||||||
|
* Scenario:
|
||||||
|
* - Client 0 creates file A with content "hello"
|
||||||
|
* - Client 1 creates file A with content "world"
|
||||||
|
* - Both clients sync
|
||||||
|
* - The system must resolve the conflict deterministically
|
||||||
|
*
|
||||||
|
* Expected behavior:
|
||||||
|
* - One version wins (typically last-write-wins or version-based)
|
||||||
|
* - Both clients converge to the same final state
|
||||||
|
*/
|
||||||
|
export const writeWriteConflictTest: TestDefinition = {
|
||||||
|
name: "Write/Write Conflict",
|
||||||
|
description:
|
||||||
|
"Two clients simultaneously create the same file with different content. " +
|
||||||
|
"The system should resolve the conflict and both clients should converge.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
// Both clients go offline
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
|
// Both clients create the same file with different content
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
|
{ type: "create", client: 1, path: "A.md", content: "world" },
|
||||||
|
|
||||||
|
// Enable sync and wait for conflict resolution
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
// Wait for sync to complete and propagate
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Extra time for any conflict resolution
|
||||||
|
{ type: "wait", duration: 300 },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Verify both clients have the same file(s) and content
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function assert(value: boolean, message: string): asserts value {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export async function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
12
frontend/deterministic-tests/tsconfig.json
Normal file
12
frontend/deterministic-tests/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["DOM", "ES2024"],
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"exclude": ["./dist"]
|
||||||
|
}
|
||||||
30
frontend/deterministic-tests/webpack.config.js
Normal file
30
frontend/deterministic-tests/webpack.config.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
const path = require("path");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: "./src/cli.ts",
|
||||||
|
target: "node",
|
||||||
|
mode: "production",
|
||||||
|
optimization: {
|
||||||
|
minimize: false
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.ts$/,
|
||||||
|
use: "ts-loader"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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 })
|
||||||
|
]
|
||||||
|
};
|
||||||
52
frontend/package-lock.json
generated
52
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
|
"deterministic-tests",
|
||||||
"local-client-cli"
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -20,6 +21,23 @@
|
||||||
"typescript-eslint": "8.49.0"
|
"typescript-eslint": "8.49.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deterministic-tests": {
|
||||||
|
"version": "0.14.0",
|
||||||
|
"bin": {
|
||||||
|
"deterministic-tests": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"sync-client": "file:../sync-client",
|
||||||
|
"ts-loader": "^9.5.4",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"webpack": "^5.103.0",
|
||||||
|
"webpack-cli": "^6.0.1",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"local-client-cli": {
|
"local-client-cli": {
|
||||||
"version": "0.14.0",
|
"version": "0.14.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -537,6 +555,15 @@
|
||||||
"@types/estree": "*"
|
"@types/estree": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.49.0",
|
"version": "8.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
||||||
|
|
@ -1486,6 +1513,10 @@
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deterministic-tests": {
|
||||||
|
"resolved": "deterministic-tests",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/dettle": {
|
"node_modules/dettle": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz",
|
||||||
|
|
@ -4080,6 +4111,27 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
|
"deterministic-tests",
|
||||||
"local-client-cli"
|
"local-client-cli"
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
|
|
@ -29,7 +30,7 @@
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
"lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
|
||||||
"update": "ncu -u -ws"
|
"update": "ncu -u -ws"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export class SyncService {
|
||||||
|
|
||||||
public async create({
|
public async create({
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes,
|
contentBytes
|
||||||
}: {
|
}: {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
|
|
@ -151,7 +151,8 @@ export class SyncService {
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -203,7 +204,8 @@ export class SyncService {
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
`Updated document ${JSON.stringify(result)} with id ${
|
||||||
|
result.documentId
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
|
|
||||||
export interface CreateDocumentVersion {
|
export interface CreateDocumentVersion {
|
||||||
relative_path: string;
|
relative_path: string;
|
||||||
force_merge: boolean | null;
|
|
||||||
content: number[];
|
content: number[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export class SyncClient {
|
||||||
database: Partial<StoredDatabase>;
|
database: Partial<StoredDatabase>;
|
||||||
}>
|
}>
|
||||||
>
|
>
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public get documentCount(): number {
|
public get documentCount(): number {
|
||||||
return this.database.length;
|
return this.database.length;
|
||||||
|
|
@ -369,7 +369,7 @@ export class SyncClient {
|
||||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||||
|
|
||||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||||
return this.syncer.syncLocallyCreatedFile(relativePath,);
|
return this.syncer.syncLocallyCreatedFile(relativePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async syncLocallyDeletedFile(
|
public async syncLocallyDeletedFile(
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async syncLocallyCreatedFile(
|
public async syncLocallyCreatedFile(
|
||||||
relativePath: RelativePath,
|
relativePath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath)
|
this.database.getLatestDocumentByRelativePath(relativePath)
|
||||||
|
|
@ -96,7 +96,6 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [promise, resolve, reject] = createPromise();
|
const [promise, resolve, reject] = createPromise();
|
||||||
this.logger.warn(`creating ${relativePath} locally`);
|
|
||||||
|
|
||||||
const document = this.database.createNewPendingDocument(
|
const document = this.database.createNewPendingDocument(
|
||||||
relativePath,
|
relativePath,
|
||||||
|
|
@ -106,12 +105,9 @@ export class Syncer {
|
||||||
try {
|
try {
|
||||||
await this.syncQueue.add(async () =>
|
await this.syncQueue.add(async () =>
|
||||||
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
|
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
|
||||||
{ document, forceMerge }
|
{ document }
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
|
|
||||||
this.logger.warn(`done creating ${relativePath} locally`);
|
|
||||||
|
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -149,7 +145,9 @@ export class Syncer {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.syncQueue.add(async () =>
|
await this.syncQueue.add(async () =>
|
||||||
this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile(document)
|
this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile(
|
||||||
|
document
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -191,8 +189,6 @@ export class Syncer {
|
||||||
let document =
|
let document =
|
||||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||||
|
|
||||||
this.logger.warn(`sync doc ${JSON.stringify(document)} for path ${relativePath} (old path: ${oldPath}), len docs: ${document?.updates.length}`);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
oldPath !== undefined &&
|
oldPath !== undefined &&
|
||||||
document?.metadata?.remoteRelativePath === relativePath
|
document?.metadata?.remoteRelativePath === relativePath
|
||||||
|
|
@ -224,14 +220,15 @@ export class Syncer {
|
||||||
relativePath,
|
relativePath,
|
||||||
promise
|
promise
|
||||||
);
|
);
|
||||||
this.logger.warn(`updating ${document.relativePath} locally`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.syncQueue.add(async () =>
|
await this.syncQueue.add(async () =>
|
||||||
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile({
|
this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
|
||||||
|
{
|
||||||
oldPath,
|
oldPath,
|
||||||
document
|
document
|
||||||
})
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -324,45 +321,37 @@ export class Syncer {
|
||||||
remoteVersion.documentId
|
remoteVersion.documentId
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.warn(`${remoteVersion.documentId} got remote update ${JSON.stringify(remoteVersion)}`);
|
|
||||||
|
|
||||||
if (document === undefined) {
|
if (document === undefined) {
|
||||||
this.logger.warn(`${remoteVersion.documentId} but document doesn't exist`)
|
|
||||||
|
|
||||||
|
|
||||||
return this.remoteDocumentsLock.withLock(
|
return this.remoteDocumentsLock.withLock(
|
||||||
// Avoid the same documents getting created in parallel multiple times through fetching multiple updates of the same
|
// Avoid the same documents getting created in parallel multiple times through fetching multiple updates of the same
|
||||||
// new remote document concurrently.
|
// new remote document concurrently.
|
||||||
// There might be multiple tasks waiting for the lock
|
// There might be multiple tasks waiting for the lock
|
||||||
remoteVersion.documentId,
|
remoteVersion.documentId,
|
||||||
async () => {
|
async () => {
|
||||||
|
|
||||||
// We have to wait for any ongoing creates sent for this file to finish,
|
// We have to wait for any ongoing creates sent for this file to finish,
|
||||||
// This is to avoid fetching one's own creates before the corresponding local create has finished syncing. This is a concern because
|
// This is to avoid fetching one's own creates before the corresponding local create has finished syncing. This is a concern because
|
||||||
// documents being created don't yet have a document id in the local database and we could be notified of the remote create
|
// documents being created don't yet have a document id in the local database and we could be notified of the remote create
|
||||||
// before the local create has finished syncing, so we can't just ignore the update based on the local DB content as we
|
// before the local create has finished syncing, so we can't just ignore the update based on the local DB content as we
|
||||||
// can't find the corresponding document yet.
|
// can't find the corresponding document yet.
|
||||||
if (document?.metadata === undefined) {
|
if (document?.metadata === undefined) {
|
||||||
await this.unrestrictedSyncer.fileCreationLock.waitForLockWithoutAcquiringLock(remoteVersion.relativePath);
|
await this.unrestrictedSyncer.fileCreationLock.waitForLockWithoutAcquiringLock(
|
||||||
|
remoteVersion.relativePath
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
document = this.database.getDocumentByDocumentId(
|
document = this.database.getDocumentByDocumentId(
|
||||||
remoteVersion.documentId
|
remoteVersion.documentId
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.warn(`${remoteVersion.documentId} rechecking, document is now ${JSON.stringify(document)}`)
|
|
||||||
|
|
||||||
// We're the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
// We're the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
||||||
if (document === undefined) {
|
if (document === undefined) {
|
||||||
this.logger.warn(`${remoteVersion.documentId} document is undefined, creating new document`)
|
|
||||||
await this.syncQueue.add(async () =>
|
await this.syncQueue.add(async () =>
|
||||||
this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
|
||||||
remoteVersion
|
remoteVersion
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [promise, resolve, reject] =
|
const [promise, resolve, reject] = createPromise();
|
||||||
createPromise();
|
|
||||||
|
|
||||||
document =
|
document =
|
||||||
await this.database.getResolvedDocumentByRelativePath(
|
await this.database.getResolvedDocumentByRelativePath(
|
||||||
|
|
@ -382,19 +371,13 @@ export class Syncer {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
} finally {
|
} finally {
|
||||||
this.database.removeDocumentPromise(
|
this.database.removeDocumentPromise(promise);
|
||||||
promise
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.database.addSeenUpdateId(
|
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId);
|
||||||
remoteVersion.vaultUpdateId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
} else {
|
|
||||||
this.logger.warn(`${remoteVersion.documentId} and document exists (path: ${JSON.stringify(document)})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
|
||||||
|
|
@ -440,7 +423,11 @@ export class Syncer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Instruction { "type": "update" | "create", relativePath: string, oldPath?: string }
|
interface Instruction {
|
||||||
|
type: "update" | "create";
|
||||||
|
relativePath: string;
|
||||||
|
oldPath?: string;
|
||||||
|
}
|
||||||
const instructions: (Instruction | undefined)[] = await awaitAll(
|
const instructions: (Instruction | undefined)[] = await awaitAll(
|
||||||
allLocalFiles.map(async (relativePath) => {
|
allLocalFiles.map(async (relativePath) => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -499,7 +486,6 @@ export class Syncer {
|
||||||
oldPath: originalFile.relativePath,
|
oldPath: originalFile.relativePath,
|
||||||
relativePath
|
relativePath
|
||||||
} as Instruction;
|
} as Instruction;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|
@ -513,7 +499,6 @@ export class Syncer {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// this has to happen strictly after the previous awaitAll, as that one
|
// this has to happen strictly after the previous awaitAll, as that one
|
||||||
// might have removed some of the documents from the list
|
// might have removed some of the documents from the list
|
||||||
await awaitAll(
|
await awaitAll(
|
||||||
|
|
@ -527,8 +512,8 @@ export class Syncer {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await awaitAll(
|
||||||
await awaitAll(instructions.map(async (instruction) => {
|
instructions.map(async (instruction) => {
|
||||||
if (instruction === undefined) {
|
if (instruction === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -538,24 +523,27 @@ export class Syncer {
|
||||||
await this.syncLocallyUpdatedFile({
|
await this.syncLocallyUpdatedFile({
|
||||||
oldPath: instruction.oldPath,
|
oldPath: instruction.oldPath,
|
||||||
relativePath: instruction.relativePath
|
relativePath: instruction.relativePath
|
||||||
}); return;
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// we have to ensure the deletes & updates have finished before starting creates,
|
// we have to ensure the deletes & updates have finished before starting creates,
|
||||||
// otherwise the server might return an existing document (that we're about to delete)
|
// otherwise the server might return an existing document (that we're about to delete)
|
||||||
// instead of actually creating a new one
|
// instead of actually creating a new one
|
||||||
await awaitAll(instructions.map(async (instruction) => {
|
await awaitAll(
|
||||||
|
instructions.map(async (instruction) => {
|
||||||
if (instruction === undefined) {
|
if (instruction === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instruction.type === "create") {
|
if (instruction.type === "create") {
|
||||||
// We're outside of the pqueue, so we need to call the public wrapper
|
// We're outside of the pqueue, so we need to call the public wrapper
|
||||||
await this.syncLocallyCreatedFile(instruction.relativePath,); return;
|
await this.syncLocallyCreatedFile(instruction.relativePath);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ import type { ServerConfig } from "../services/server-config";
|
||||||
import { Locks } from "../utils/data-structures/locks";
|
import { Locks } from "../utils/data-structures/locks";
|
||||||
|
|
||||||
export class UnrestrictedSyncer {
|
export class UnrestrictedSyncer {
|
||||||
|
public readonly fileCreationLock: Locks<RelativePath> =
|
||||||
|
new Locks<RelativePath>();
|
||||||
private ignorePatterns: RegExp[];
|
private ignorePatterns: RegExp[];
|
||||||
public readonly fileCreationLock: Locks<RelativePath> = new Locks<RelativePath>();
|
|
||||||
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
|
@ -74,32 +74,31 @@ export class UnrestrictedSyncer {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
document: DocumentRecord;
|
document: DocumentRecord;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
|
||||||
// this.history.addHistoryEntry({
|
// this.history.addHistoryEntry({
|
||||||
// status: SyncStatus.SUCCESS,
|
// status: SyncStatus.SUCCESS,
|
||||||
// details: updateDetails,
|
// details: updateDetails,
|
||||||
// message: `Successfully uploaded locally created file`
|
// message: `Successfully uploaded locally created file`
|
||||||
// });
|
// });
|
||||||
|
|
||||||
let updateDetails: SyncCreateDetails | SyncUpdateDetails | SyncMovedDetails;
|
const updateDetails:
|
||||||
if (document.metadata === undefined) {
|
| SyncCreateDetails
|
||||||
updateDetails = {
|
| SyncUpdateDetails
|
||||||
|
| SyncMovedDetails =
|
||||||
|
document.metadata === undefined
|
||||||
|
? {
|
||||||
type: SyncType.CREATE,
|
type: SyncType.CREATE,
|
||||||
relativePath: document.relativePath
|
relativePath: document.relativePath
|
||||||
};
|
|
||||||
}
|
}
|
||||||
else if (oldPath !== undefined) {
|
: oldPath !== undefined
|
||||||
updateDetails = {
|
? {
|
||||||
type: SyncType.MOVE,
|
type: SyncType.MOVE,
|
||||||
relativePath: document.relativePath,
|
relativePath: document.relativePath,
|
||||||
movedFrom: oldPath
|
movedFrom: oldPath
|
||||||
};
|
}
|
||||||
} else {
|
: {
|
||||||
updateDetails = {
|
|
||||||
type: SyncType.UPDATE,
|
type: SyncType.UPDATE,
|
||||||
relativePath: document.relativePath
|
relativePath: document.relativePath
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await this.executeSync(updateDetails, async () => {
|
await this.executeSync(updateDetails, async () => {
|
||||||
const originalRelativePath = document.relativePath;
|
const originalRelativePath = document.relativePath;
|
||||||
|
|
@ -116,31 +115,33 @@ export class UnrestrictedSyncer {
|
||||||
); // this can throw FileNotFoundError
|
); // this can throw FileNotFoundError
|
||||||
const contentHash = hash(contentBytes);
|
const contentHash = hash(contentBytes);
|
||||||
|
|
||||||
this.logger.warn(`updating ${document.relativePath} locally, inner`);
|
|
||||||
|
|
||||||
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
if (document.metadata === undefined) {
|
if (document.metadata === undefined) {
|
||||||
response = await this.fileCreationLock.withLock(document.relativePath, async () => {
|
response = await this.fileCreationLock.withLock(
|
||||||
const response = await this.syncService.create({
|
document.relativePath,
|
||||||
|
async () => {
|
||||||
|
const createResponse = await this.syncService.create({
|
||||||
relativePath: originalRelativePath,
|
relativePath: originalRelativePath,
|
||||||
contentBytes,
|
contentBytes
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.handleMaybeMergingResponse({
|
await this.handleMaybeMergingResponse({
|
||||||
document,
|
document,
|
||||||
response,
|
response: createResponse,
|
||||||
contentHash,
|
contentHash,
|
||||||
originalRelativePath,
|
originalRelativePath,
|
||||||
originalContentBytes: contentBytes
|
originalContentBytes: contentBytes
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return createResponse;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const areThereLocalChanges =
|
const areThereLocalChanges =
|
||||||
document.metadata.hash !== contentHash || oldPath !== undefined;
|
document.metadata.hash !== contentHash ||
|
||||||
|
oldPath !== undefined;
|
||||||
|
|
||||||
if (areThereLocalChanges) {
|
if (areThereLocalChanges) {
|
||||||
const isText =
|
const isText =
|
||||||
|
|
@ -196,8 +197,6 @@ export class UnrestrictedSyncer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!("type" in response) || response.type === "MergingUpdate") {
|
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
this.history.addHistoryEntry({
|
this.history.addHistoryEntry({
|
||||||
|
|
@ -255,7 +254,6 @@ export class UnrestrictedSyncer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async unrestrictedSyncLocallyDeletedFile(
|
public async unrestrictedSyncLocallyDeletedFile(
|
||||||
document: DocumentRecord
|
document: DocumentRecord
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -307,7 +305,6 @@ export class UnrestrictedSyncer {
|
||||||
relativePath: remoteVersion.relativePath
|
relativePath: remoteVersion.relativePath
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
await this.executeSync(updateDetails, async () => {
|
await this.executeSync(updateDetails, async () => {
|
||||||
if (document?.metadata !== undefined) {
|
if (document?.metadata !== undefined) {
|
||||||
// If the file exists locally, let's pretend the user has updated it
|
// If the file exists locally, let's pretend the user has updated it
|
||||||
|
|
@ -474,8 +471,6 @@ export class UnrestrictedSyncer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private async handleMaybeMergingResponse({
|
private async handleMaybeMergingResponse({
|
||||||
document,
|
document,
|
||||||
response,
|
response,
|
||||||
|
|
@ -584,7 +579,8 @@ export class UnrestrictedSyncer {
|
||||||
type: SyncType.SKIPPED,
|
type: SyncType.SKIPPED,
|
||||||
relativePath
|
relativePath
|
||||||
},
|
},
|
||||||
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB
|
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${
|
||||||
|
maxFileSizeMB
|
||||||
} MB`
|
} MB`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export class Locks<T> {
|
||||||
[() => unknown, (err: unknown) => unknown][]
|
[() => unknown, (err: unknown) => unknown][]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
public constructor(private readonly logger?: Logger) { }
|
public constructor(private readonly logger?: Logger) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function while holding exclusive locks on one or more keys.
|
* Executes a function while holding exclusive locks on one or more keys.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,10 @@ export class MockAgent extends MockClient {
|
||||||
case LogLevel.ERROR:
|
case LogLevel.ERROR:
|
||||||
console.error(formatted);
|
console.error(formatted);
|
||||||
|
|
||||||
if (!this.useSlowFileEvents && !formatted.includes("retrying in")) {
|
if (
|
||||||
|
!this.useSlowFileEvents &&
|
||||||
|
!formatted.includes("retrying in")
|
||||||
|
) {
|
||||||
// Let's wait for the error to be caught if there was one
|
// Let's wait for the error to be caught if there was one
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
sleep(100).then(() => {
|
sleep(100).then(() => {
|
||||||
|
|
@ -307,7 +310,9 @@ export class MockAgent extends MockClient {
|
||||||
`Decided to create file ${file} with content ${content}`
|
`Decided to create file ${file} with content ${content}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.create(file, new TextEncoder().encode(` ${content} `), { ignoreSlowFileEvents: true });
|
return this.create(file, new TextEncoder().encode(` ${content} `), {
|
||||||
|
ignoreSlowFileEvents: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disableSyncAction(): Promise<void> {
|
private async disableSyncAction(): Promise<void> {
|
||||||
|
|
@ -371,10 +376,14 @@ export class MockAgent extends MockClient {
|
||||||
`Decided to update file ${file} with ${content}`
|
`Decided to update file ${file} with ${content}`
|
||||||
);
|
);
|
||||||
this.doNotTouchWhileOffline.push(file);
|
this.doNotTouchWhileOffline.push(file);
|
||||||
await this.atomicUpdateText(file, (old) => ({
|
await this.atomicUpdateText(
|
||||||
|
file,
|
||||||
|
(old) => ({
|
||||||
text: old.text + ` ${content} `,
|
text: old.text + ` ${content} `,
|
||||||
cursors: []
|
cursors: []
|
||||||
}), { ignoreSlowFileEvents: true });
|
}),
|
||||||
|
{ ignoreSlowFileEvents: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteFileAction(files: RelativePath[]): Promise<void> {
|
private async deleteFileAction(files: RelativePath[]): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ export class MockClient implements FileSystemOperations {
|
||||||
public async create(
|
public async create(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
newContent: Uint8Array,
|
newContent: Uint8Array,
|
||||||
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
|
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
|
||||||
|
ignoreSlowFileEvents: false
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.localFiles.has(path)) {
|
if (this.localFiles.has(path)) {
|
||||||
throw new Error(`File ${path} already exists`);
|
throw new Error(`File ${path} already exists`);
|
||||||
|
|
@ -75,9 +77,10 @@ export class MockClient implements FileSystemOperations {
|
||||||
);
|
);
|
||||||
this.localFiles.set(path, newContent);
|
this.localFiles.set(path, newContent);
|
||||||
|
|
||||||
this.executeFileOperation((async () =>
|
this.executeFileOperation(
|
||||||
this.client.syncLocallyCreatedFile(path)
|
async () => this.client.syncLocallyCreatedFile(path),
|
||||||
), ignoreSlowFileEvents);
|
ignoreSlowFileEvents
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||||
|
|
@ -87,7 +90,9 @@ export class MockClient implements FileSystemOperations {
|
||||||
public async atomicUpdateText(
|
public async atomicUpdateText(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
updater: (currentContent: TextWithCursors) => TextWithCursors,
|
updater: (currentContent: TextWithCursors) => TextWithCursors,
|
||||||
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
|
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
|
||||||
|
ignoreSlowFileEvents: false
|
||||||
|
}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const file = this.localFiles.get(path);
|
const file = this.localFiles.get(path);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
@ -118,11 +123,13 @@ export class MockClient implements FileSystemOperations {
|
||||||
`Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}`
|
`Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.executeFileOperation((async () =>
|
this.executeFileOperation(
|
||||||
|
async () =>
|
||||||
this.client.syncLocallyUpdatedFile({
|
this.client.syncLocallyUpdatedFile({
|
||||||
relativePath: path
|
relativePath: path
|
||||||
})
|
}),
|
||||||
), ignoreSlowFileEvents);
|
ignoreSlowFileEvents
|
||||||
|
);
|
||||||
|
|
||||||
return newContent;
|
return newContent;
|
||||||
}
|
}
|
||||||
|
|
@ -146,21 +153,29 @@ export class MockClient implements FileSystemOperations {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }): Promise<void> {
|
public async delete(
|
||||||
|
path: RelativePath,
|
||||||
|
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
|
||||||
|
ignoreSlowFileEvents: false
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
this.client.logger.info(
|
this.client.logger.info(
|
||||||
`Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}`
|
`Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}`
|
||||||
);
|
);
|
||||||
this.localFiles.delete(path);
|
this.localFiles.delete(path);
|
||||||
|
|
||||||
this.executeFileOperation((async () =>
|
this.executeFileOperation(
|
||||||
this.client.syncLocallyDeletedFile(path)
|
async () => this.client.syncLocallyDeletedFile(path),
|
||||||
), ignoreSlowFileEvents);
|
ignoreSlowFileEvents
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async rename(
|
public async rename(
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath,
|
newPath: RelativePath,
|
||||||
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
|
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
|
||||||
|
ignoreSlowFileEvents: false
|
||||||
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const file = this.localFiles.get(oldPath);
|
const file = this.localFiles.get(oldPath);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
@ -175,15 +190,20 @@ export class MockClient implements FileSystemOperations {
|
||||||
`Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}`
|
`Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.executeFileOperation((async () =>
|
this.executeFileOperation(
|
||||||
|
async () =>
|
||||||
this.client.syncLocallyUpdatedFile({
|
this.client.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath: newPath
|
relativePath: newPath
|
||||||
})
|
}),
|
||||||
), ignoreSlowFileEvents);
|
ignoreSlowFileEvents
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeFileOperation(callback: () => unknown, ignoreSlowFileEvents = false): void {
|
private executeFileOperation(
|
||||||
|
callback: () => unknown,
|
||||||
|
ignoreSlowFileEvents = false
|
||||||
|
): void {
|
||||||
if (this.useSlowFileEvents && !ignoreSlowFileEvents) {
|
if (this.useSlowFileEvents && !ignoreSlowFileEvents) {
|
||||||
// we aren't the best client and it takes some time to notice changes
|
// we aren't the best client and it takes some time to notice changes
|
||||||
setTimeout(callback, Math.random() * 100);
|
setTimeout(callback, Math.random() * 100);
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,6 @@ async function runTest({
|
||||||
slowFileEvents = useSlowFileEvents;
|
slowFileEvents = useSlowFileEvents;
|
||||||
doResets = useResets;
|
doResets = useResets;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`;
|
const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`;
|
||||||
logger.info(`Running test ${settings}`);
|
logger.info(`Running test ${settings}`);
|
||||||
|
|
||||||
|
|
@ -70,7 +68,9 @@ async function runTest({
|
||||||
await utils.awaitAll(clients.map(async (client) => client.init()));
|
await utils.awaitAll(clients.map(async (client) => client.init()));
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
const initialDocCount = Math.floor(Math.random() * MAX_INITIAL_DOCS);
|
const initialDocCount = Math.floor(
|
||||||
|
Math.random() * MAX_INITIAL_DOCS
|
||||||
|
);
|
||||||
if (initialDocCount > 0) {
|
if (initialDocCount > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Creating ${initialDocCount} initial documents for ${client.name}`
|
`Creating ${initialDocCount} initial documents for ${client.name}`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue