Format & lint
This commit is contained in:
parent
fefac224b0
commit
7f62273e72
179 changed files with 2149 additions and 1258 deletions
|
|
@ -17,20 +17,25 @@ All tests run in parallel up to a concurrency limit.
|
||||||
Clients always start with syncing disabled.
|
Clients always start with syncing disabled.
|
||||||
|
|
||||||
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
||||||
|
|
||||||
- `create`, `update`, `rename`, `delete`
|
- `create`, `update`, `rename`, `delete`
|
||||||
|
|
||||||
**Sync control:**
|
**Sync control:**
|
||||||
|
|
||||||
- `sync` — wait for a specific client or all clients to finish pending operations
|
- `sync` — wait for a specific client or all clients to finish pending operations
|
||||||
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
||||||
- `enable-sync` / `disable-sync` — simulate going online/offline
|
- `enable-sync` / `disable-sync` — simulate going online/offline
|
||||||
|
|
||||||
**WebSocket control** (per-client):
|
**WebSocket control** (per-client):
|
||||||
|
|
||||||
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
||||||
|
|
||||||
**Server control:**
|
**Server control:**
|
||||||
|
|
||||||
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
||||||
|
|
||||||
**Assertions:**
|
**Assertions:**
|
||||||
|
|
||||||
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
|
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
@ -57,15 +62,19 @@ npm run test -w deterministic-tests -- -j 4
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const myScenarioTest: TestDefinition = {
|
export const myScenarioTest: TestDefinition = {
|
||||||
description: "Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
description:
|
||||||
clients: 2,
|
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
||||||
steps: [
|
clients: 2,
|
||||||
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "barrier" },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") }
|
{ type: "barrier" },
|
||||||
]
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello")
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -88,7 +97,7 @@ s.ifFileExists("path", (s) => ...) // conditional assertion
|
||||||
import { myScenarioTest } from "./tests/my-scenario.test";
|
import { myScenarioTest } from "./tests/my-scenario.test";
|
||||||
|
|
||||||
const TESTS = {
|
const TESTS = {
|
||||||
// ...
|
// ...
|
||||||
"my-scenario": myScenarioTest
|
"my-scenario": myScenarioTest
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -38,137 +38,6 @@ interface NamedTestResult {
|
||||||
result: TestResult;
|
result: TestResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
let projectRoot = cwd;
|
|
||||||
|
|
||||||
if (cwd.endsWith("frontend/deterministic-tests")) {
|
|
||||||
projectRoot = path.resolve(cwd, "../..");
|
|
||||||
} else if (cwd.endsWith("frontend")) {
|
|
||||||
projectRoot = path.resolve(cwd, "..");
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
|
||||||
logger.error(`Server binary not found at: ${serverPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(projectRoot, CONFIG_PATH);
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
logger.error(`Config file not found at: ${configPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
|
|
||||||
const filter = filterArg?.slice("--filter=".length);
|
|
||||||
|
|
||||||
const testsToRun: [string, TestDefinition][] = [];
|
|
||||||
for (const [key, test] of Object.entries(TESTS)) {
|
|
||||||
if (test) {
|
|
||||||
if (filter && !key.includes(filter)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
testsToRun.push([key, test]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testsToRun.length === 0) {
|
|
||||||
logger.error(
|
|
||||||
filter
|
|
||||||
? `No tests matched filter "${filter}"`
|
|
||||||
: "No tests found"
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const concurrency = parseConcurrency();
|
|
||||||
const regularTests = testsToRun.filter(
|
|
||||||
([, t]) => !testUsesPauseServer(t)
|
|
||||||
);
|
|
||||||
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
|
||||||
|
|
||||||
logger.info(`Server: ${serverPath}`);
|
|
||||||
logger.info(`Config: ${configPath}`);
|
|
||||||
logger.info(
|
|
||||||
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
|
|
||||||
);
|
|
||||||
logger.info(`Concurrency: ${concurrency}`);
|
|
||||||
|
|
||||||
const allResults: NamedTestResult[] = [];
|
|
||||||
|
|
||||||
if (regularTests.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
|
|
||||||
);
|
|
||||||
const sharedServer = new ServerControl(
|
|
||||||
serverPath,
|
|
||||||
configPath,
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
serverManager.track(sharedServer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sharedServer.start();
|
|
||||||
|
|
||||||
const results = await runWithConcurrency(
|
|
||||||
regularTests,
|
|
||||||
concurrency,
|
|
||||||
async ([name, test]) =>
|
|
||||||
runSharedServerTest(name, test, sharedServer)
|
|
||||||
);
|
|
||||||
|
|
||||||
allResults.push(...results);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await sharedServer.stop();
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
serverManager.untrack(sharedServer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pauseTests.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await runWithConcurrency(
|
|
||||||
pauseTests,
|
|
||||||
concurrency,
|
|
||||||
async ([name, test]) =>
|
|
||||||
runDedicatedServerTest(name, test, serverPath, configPath)
|
|
||||||
);
|
|
||||||
|
|
||||||
allResults.push(...results);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passed = allResults.filter((r) => r.result.success);
|
|
||||||
const failed = allResults.filter((r) => !r.result.success);
|
|
||||||
|
|
||||||
logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`);
|
|
||||||
|
|
||||||
if (failed.length > 0) {
|
|
||||||
for (const { name, result } of failed) {
|
|
||||||
logger.error(` FAILED: ${name}: ${result.error}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
logger.info("All tests passed!");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err: unknown) => {
|
|
||||||
logger.error(`Unexpected error: ${err}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
async function runSharedServerTest(
|
async function runSharedServerTest(
|
||||||
name: string,
|
name: string,
|
||||||
test: TestDefinition,
|
test: TestDefinition,
|
||||||
|
|
@ -229,3 +98,132 @@ async function runDedicatedServerTest(
|
||||||
serverManager.untrack(server);
|
serverManager.untrack(server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let projectRoot = cwd;
|
||||||
|
|
||||||
|
if (cwd.endsWith("frontend/deterministic-tests")) {
|
||||||
|
projectRoot = path.resolve(cwd, "../..");
|
||||||
|
} else if (cwd.endsWith("frontend")) {
|
||||||
|
projectRoot = path.resolve(cwd, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
logger.error(`Server binary not found at: ${serverPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(projectRoot, CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
logger.error(`Config file not found at: ${configPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
|
||||||
|
const filter = filterArg?.slice("--filter=".length);
|
||||||
|
|
||||||
|
const testsToRun: [string, TestDefinition][] = [];
|
||||||
|
for (const [key, test] of Object.entries(TESTS)) {
|
||||||
|
if (test) {
|
||||||
|
if (
|
||||||
|
filter !== undefined &&
|
||||||
|
filter.length > 0 &&
|
||||||
|
!key.includes(filter)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
testsToRun.push([key, test]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testsToRun.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
filter !== undefined && filter.length > 0
|
||||||
|
? `No tests matched filter "${filter}"`
|
||||||
|
: "No tests found"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const concurrency = parseConcurrency();
|
||||||
|
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
|
||||||
|
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
||||||
|
|
||||||
|
logger.info(`Server: ${serverPath}`);
|
||||||
|
logger.info(`Config: ${configPath}`);
|
||||||
|
logger.info(
|
||||||
|
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
|
||||||
|
);
|
||||||
|
logger.info(`Concurrency: ${concurrency}`);
|
||||||
|
|
||||||
|
const allResults: NamedTestResult[] = [];
|
||||||
|
|
||||||
|
if (regularTests.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
|
||||||
|
);
|
||||||
|
const sharedServer = new ServerControl(serverPath, configPath, logger);
|
||||||
|
serverManager.track(sharedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sharedServer.start();
|
||||||
|
|
||||||
|
const results = await runWithConcurrency(
|
||||||
|
regularTests,
|
||||||
|
concurrency,
|
||||||
|
async ([name, test]) =>
|
||||||
|
runSharedServerTest(name, test, sharedServer)
|
||||||
|
);
|
||||||
|
|
||||||
|
allResults.push(...results);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await sharedServer.stop();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serverManager.untrack(sharedServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pauseTests.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await runWithConcurrency(
|
||||||
|
pauseTests,
|
||||||
|
concurrency,
|
||||||
|
async ([name, test]) =>
|
||||||
|
runDedicatedServerTest(name, test, serverPath, configPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
allResults.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = allResults.filter((r) => r.result.success);
|
||||||
|
const failed = allResults.filter((r) => !r.result.success);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
for (const { name, result } of failed) {
|
||||||
|
logger.error(` FAILED: ${name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
logger.info("All tests passed!");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
logger.error(`Unexpected error: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client";
|
import type {
|
||||||
import { SyncClient, debugging, LogLevel } from "sync-client";
|
StoredDatabase,
|
||||||
|
SyncSettings,
|
||||||
|
RelativePath,
|
||||||
|
TextWithCursors
|
||||||
|
} from "sync-client";
|
||||||
|
import { SyncClient, debugging, LogLevel, utils } from "sync-client";
|
||||||
import { assert } from "./utils/assert";
|
import { assert } from "./utils/assert";
|
||||||
import { sleep } from "./utils/sleep";
|
import { sleep } from "./utils/sleep";
|
||||||
import { withTimeout } from "./utils/with-timeout";
|
import { withTimeout } from "./utils/with-timeout";
|
||||||
import { IS_SYNC_ENABLED_BY_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts";
|
import {
|
||||||
|
IS_SYNC_ENABLED_BY_DEFAULT,
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
WEBSOCKET_CONNECT_TIMEOUT_MS,
|
||||||
|
WEBSOCKET_POLL_INTERVAL_MS
|
||||||
|
} from "./consts";
|
||||||
import { ManagedWebSocketFactory } from "./managed-websocket";
|
import { ManagedWebSocketFactory } from "./managed-websocket";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
public readonly clientId: number;
|
public readonly clientId: number;
|
||||||
private readonly logger: (msg: string) => void;
|
private readonly logger: (msg: string) => void;
|
||||||
|
|
@ -33,7 +41,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(
|
public async init(
|
||||||
fetchImplementation: typeof globalThis.fetch,
|
fetchImplementation: typeof globalThis.fetch
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.client = await SyncClient.create({
|
this.client = await SyncClient.create({
|
||||||
fs: this,
|
fs: this,
|
||||||
|
|
@ -138,7 +146,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
await this.waitForWebSocket();
|
await this.waitForWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async getFileContent(path: string): Promise<string> {
|
public async getFileContent(path: string): Promise<string> {
|
||||||
const bytes = await this.read(path);
|
const bytes = await this.read(path);
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
|
|
@ -146,9 +153,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
|
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
this.log("Cleaning up...");
|
this.log("Cleaning up...");
|
||||||
// Guard against uninitialized client (init() failed partway)
|
// Guard against uninitialized client (init() failed partway).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// The class field uses `!:` so TS thinks this is always defined,
|
||||||
if (!this.client) {
|
// but at runtime it can be undefined when init() throws partway.
|
||||||
|
const maybeClient = this.client as SyncClient | undefined;
|
||||||
|
if (maybeClient === undefined) {
|
||||||
this.log("Client not initialized, nothing to clean up");
|
this.log("Client not initialized, nothing to clean up");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -184,11 +193,13 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
await super.write(path, content);
|
await super.write(path, content);
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyCreatedFile(path);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,18 +208,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const result = await super.atomicUpdateText(path, updater);
|
const result = await super.atomicUpdateText(path, updater);
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override async delete(path: RelativePath): Promise<void> {
|
public override async delete(path: RelativePath): Promise<void> {
|
||||||
await super.delete(path);
|
await super.delete(path);
|
||||||
if (this.isSyncEnabled) {
|
if (this.isSyncEnabled) {
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyDeletedFile(path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,8 +233,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath: newPath
|
relativePath: newPath
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForWebSocket(): Promise<void> {
|
private async waitForWebSocket(): Promise<void> {
|
||||||
|
|
@ -243,7 +253,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
*/
|
*/
|
||||||
private async drainPendingSyncOperations(): Promise<void> {
|
private async drainPendingSyncOperations(): Promise<void> {
|
||||||
while (this.pendingSyncOperations.size > 0) {
|
while (this.pendingSyncOperations.size > 0) {
|
||||||
await Promise.all(this.pendingSyncOperations);
|
await utils.awaitAll([...this.pendingSyncOperations]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,129 @@
|
||||||
* A WebSocket wrapper that can pause and resume message delivery.
|
* A WebSocket wrapper that can pause and resume message delivery.
|
||||||
* When paused, incoming messages are buffered. When resumed, buffered
|
* When paused, incoming messages are buffered. When resumed, buffered
|
||||||
* messages are delivered in order via the onmessage handler.
|
* messages are delivered in order via the onmessage handler.
|
||||||
|
*
|
||||||
|
* Member layout follows typescript-eslint default member-ordering: all
|
||||||
|
* accessor properties are declared with `declare` and wired through the
|
||||||
|
* constructor using Object.defineProperty so we don't need conflicting
|
||||||
|
* get/set accessor pairs.
|
||||||
*/
|
*/
|
||||||
export class ManagedWebSocket implements WebSocket {
|
export class ManagedWebSocket implements WebSocket {
|
||||||
|
public static readonly CONNECTING = WebSocket.CONNECTING;
|
||||||
|
public static readonly OPEN = WebSocket.OPEN;
|
||||||
|
public static readonly CLOSING = WebSocket.CLOSING;
|
||||||
|
public static readonly CLOSED = WebSocket.CLOSED;
|
||||||
|
|
||||||
|
public readonly CONNECTING = WebSocket.CONNECTING;
|
||||||
|
public readonly OPEN = WebSocket.OPEN;
|
||||||
|
public readonly CLOSING = WebSocket.CLOSING;
|
||||||
|
public readonly CLOSED = WebSocket.CLOSED;
|
||||||
|
|
||||||
|
declare public readonly readyState: number;
|
||||||
|
declare public readonly url: string;
|
||||||
|
declare public readonly protocol: string;
|
||||||
|
declare public readonly extensions: string;
|
||||||
|
declare public readonly bufferedAmount: number;
|
||||||
|
declare public binaryType: BinaryType;
|
||||||
|
declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||||
|
declare public onclose:
|
||||||
|
| ((this: WebSocket, ev: CloseEvent) => unknown)
|
||||||
|
| null;
|
||||||
|
declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||||
|
declare public onmessage:
|
||||||
|
| ((this: WebSocket, ev: MessageEvent) => unknown)
|
||||||
|
| null;
|
||||||
|
|
||||||
private readonly ws: WebSocket;
|
private readonly ws: WebSocket;
|
||||||
private paused = false;
|
|
||||||
private readonly bufferedMessages: MessageEvent[] = [];
|
private readonly bufferedMessages: MessageEvent[] = [];
|
||||||
|
private paused = false;
|
||||||
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
|
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
|
||||||
|
|
||||||
public constructor(url: string | URL, protocols?: string | string[]) {
|
public constructor(url: string | URL, protocols?: string | string[]) {
|
||||||
this.ws = new WebSocket(url, protocols);
|
this.ws = new WebSocket(url, protocols);
|
||||||
|
|
||||||
|
const { ws } = this;
|
||||||
|
Object.defineProperties(this, {
|
||||||
|
readyState: {
|
||||||
|
get: (): number => ws.readyState,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
get: (): string => ws.url,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
protocol: {
|
||||||
|
get: (): string => ws.protocol,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
extensions: {
|
||||||
|
get: (): string => ws.extensions,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
bufferedAmount: {
|
||||||
|
get: (): number => ws.bufferedAmount,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
binaryType: {
|
||||||
|
get: (): BinaryType => ws.binaryType,
|
||||||
|
set: (v: BinaryType): void => {
|
||||||
|
ws.binaryType = v;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
onopen: {
|
||||||
|
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
|
||||||
|
ws.onopen,
|
||||||
|
set: (
|
||||||
|
h: ((this: WebSocket, ev: Event) => unknown) | null
|
||||||
|
): void => {
|
||||||
|
ws.onopen = h;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
onclose: {
|
||||||
|
get: ():
|
||||||
|
| ((this: WebSocket, ev: CloseEvent) => unknown)
|
||||||
|
| null => ws.onclose,
|
||||||
|
set: (
|
||||||
|
h: ((this: WebSocket, ev: CloseEvent) => unknown) | null
|
||||||
|
): void => {
|
||||||
|
ws.onclose = h;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
onerror: {
|
||||||
|
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
|
||||||
|
ws.onerror,
|
||||||
|
set: (
|
||||||
|
h: ((this: WebSocket, ev: Event) => unknown) | null
|
||||||
|
): void => {
|
||||||
|
ws.onerror = h;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
},
|
||||||
|
onmessage: {
|
||||||
|
get: ():
|
||||||
|
| ((this: WebSocket, ev: MessageEvent) => unknown)
|
||||||
|
| null => this.externalOnMessage,
|
||||||
|
set: (
|
||||||
|
h: ((this: WebSocket, ev: MessageEvent) => unknown) | null
|
||||||
|
): void => {
|
||||||
|
this.externalOnMessage = h;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.ws.onmessage = (event: MessageEvent): void => {
|
this.ws.onmessage = (event: MessageEvent): void => {
|
||||||
if (this.paused) {
|
if (this.paused) {
|
||||||
this.bufferedMessages.push(event);
|
this.bufferedMessages.push(event);
|
||||||
|
|
@ -33,68 +146,6 @@ export class ManagedWebSocket implements WebSocket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get readyState(): number {
|
|
||||||
return this.ws.readyState;
|
|
||||||
}
|
|
||||||
|
|
||||||
get url(): string {
|
|
||||||
return this.ws.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
get protocol(): string {
|
|
||||||
return this.ws.protocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
get extensions(): string {
|
|
||||||
return this.ws.extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
get bufferedAmount(): number {
|
|
||||||
return this.ws.bufferedAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get binaryType(): BinaryType {
|
|
||||||
return this.ws.binaryType;
|
|
||||||
}
|
|
||||||
|
|
||||||
set binaryType(value: BinaryType) {
|
|
||||||
this.ws.binaryType = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onopen(): ((this: WebSocket, ev: Event) => unknown) | null {
|
|
||||||
return this.ws.onopen;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onopen(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
|
|
||||||
this.ws.onopen = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onclose(): ((this: WebSocket, ev: CloseEvent) => unknown) | null {
|
|
||||||
return this.ws.onclose;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onclose(handler: ((this: WebSocket, ev: CloseEvent) => unknown) | null) {
|
|
||||||
this.ws.onclose = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onerror(): ((this: WebSocket, ev: Event) => unknown) | null {
|
|
||||||
return this.ws.onerror;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onerror(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
|
|
||||||
this.ws.onerror = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onmessage(): ((this: WebSocket, ev: MessageEvent) => unknown) | null {
|
|
||||||
return this.externalOnMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onmessage(
|
|
||||||
handler: ((this: WebSocket, ev: MessageEvent) => unknown) | null
|
|
||||||
) {
|
|
||||||
this.externalOnMessage = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||||
this.ws.send(data);
|
this.ws.send(data);
|
||||||
}
|
}
|
||||||
|
|
@ -118,16 +169,6 @@ export class ManagedWebSocket implements WebSocket {
|
||||||
public dispatchEvent(event: Event): boolean {
|
public dispatchEvent(event: Event): boolean {
|
||||||
return this.ws.dispatchEvent(event);
|
return this.ws.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
static readonly CONNECTING = WebSocket.CONNECTING;
|
|
||||||
static readonly OPEN = WebSocket.OPEN;
|
|
||||||
static readonly CLOSING = WebSocket.CLOSING;
|
|
||||||
static readonly CLOSED = WebSocket.CLOSED;
|
|
||||||
|
|
||||||
readonly CONNECTING = WebSocket.CONNECTING;
|
|
||||||
readonly OPEN = WebSocket.OPEN;
|
|
||||||
readonly CLOSING = WebSocket.CLOSING;
|
|
||||||
readonly CLOSED = WebSocket.CLOSED;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -138,22 +179,19 @@ export class ManagedWebSocketFactory {
|
||||||
private readonly instances: ManagedWebSocket[] = [];
|
private readonly instances: ManagedWebSocket[] = [];
|
||||||
|
|
||||||
public get constructorFn(): typeof globalThis.WebSocket {
|
public get constructorFn(): typeof globalThis.WebSocket {
|
||||||
const factory = this;
|
const trackInstance = (instance: ManagedWebSocket): void => {
|
||||||
const ctor = function ManagedWS(
|
this.instances.push(instance);
|
||||||
url: string | URL,
|
};
|
||||||
protocols?: string | string[]
|
class TrackedManagedWebSocket extends ManagedWebSocket {
|
||||||
): ManagedWebSocket {
|
public constructor(
|
||||||
const ws = new ManagedWebSocket(url, protocols);
|
url: string | URL,
|
||||||
factory.instances.push(ws);
|
protocols?: string | string[]
|
||||||
return ws;
|
) {
|
||||||
} as unknown as typeof globalThis.WebSocket;
|
super(url, protocols);
|
||||||
|
trackInstance(this);
|
||||||
Object.defineProperty(ctor, "CONNECTING", { value: WebSocket.CONNECTING });
|
}
|
||||||
Object.defineProperty(ctor, "OPEN", { value: WebSocket.OPEN });
|
}
|
||||||
Object.defineProperty(ctor, "CLOSING", { value: WebSocket.CLOSING });
|
return TrackedManagedWebSocket;
|
||||||
Object.defineProperty(ctor, "CLOSED", { value: WebSocket.CLOSED });
|
|
||||||
|
|
||||||
return ctor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause(): void {
|
public pause(): void {
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,7 @@ export class ServerControl {
|
||||||
this._port = reservation.port;
|
this._port = reservation.port;
|
||||||
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
|
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
|
||||||
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
|
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
|
||||||
this.tempDir = fs.mkdtempSync(
|
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
|
||||||
path.join(tmpBase, "vault-link-test-")
|
|
||||||
);
|
|
||||||
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
||||||
const dbDir = path.join(this.tempDir, "databases");
|
const dbDir = path.join(this.tempDir, "databases");
|
||||||
|
|
||||||
|
|
@ -225,7 +223,7 @@ export class ServerControl {
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupTempDir(): void {
|
private cleanupTempDir(): void {
|
||||||
if (this.tempDir) {
|
if (this.tempDir !== undefined) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -234,5 +232,4 @@ export class ServerControl {
|
||||||
this.tempDir = undefined;
|
this.tempDir = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,18 @@ export class ServerManager {
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
this.logger.info("Received SIGINT, shutting down...");
|
this.logger.info("Received SIGINT, shutting down...");
|
||||||
void this.stopAll()
|
void this.stopAll()
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
/* no-op */
|
||||||
|
})
|
||||||
.then(() => process.exit(130));
|
.then(() => process.exit(130));
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
this.logger.info("Received SIGTERM, shutting down...");
|
this.logger.info("Received SIGTERM, shutting down...");
|
||||||
void this.stopAll()
|
void this.stopAll()
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
/* no-op */
|
||||||
|
})
|
||||||
.then(() => process.exit(143));
|
.then(() => process.exit(143));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,12 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"delete-recreate-same-path": deleteRecreateSamePathTest,
|
"delete-recreate-same-path": deleteRecreateSamePathTest,
|
||||||
"offline-rename-and-edit": offlineRenameAndEditTest,
|
"offline-rename-and-edit": offlineRenameAndEditTest,
|
||||||
"rename-to-existing-path": renameToExistingPathTest,
|
"rename-to-existing-path": renameToExistingPathTest,
|
||||||
"simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest,
|
"simultaneous-create-delete-same-path":
|
||||||
|
simultaneousCreateDeleteSamePathTest,
|
||||||
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
|
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
|
||||||
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
|
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
|
||||||
"mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest,
|
"mc-three-client-rename-offline-update":
|
||||||
|
mcThreeClientRenameOfflineUpdateTest,
|
||||||
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
|
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
|
||||||
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
|
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
|
||||||
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
|
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
|
||||||
|
|
@ -117,7 +119,8 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"rename-swap": renameSwapTest,
|
"rename-swap": renameSwapTest,
|
||||||
"rename-circular": renameCircularTest,
|
"rename-circular": renameCircularTest,
|
||||||
"rename-roundtrip": renameRoundtripTest,
|
"rename-roundtrip": renameRoundtripTest,
|
||||||
"offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest,
|
"offline-rename-remote-create-old-path":
|
||||||
|
offlineRenameRemoteCreateOldPathTest,
|
||||||
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
|
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
|
||||||
"rename-chain-then-delete": renameChainThenDeleteTest,
|
"rename-chain-then-delete": renameChainThenDeleteTest,
|
||||||
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
|
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
|
||||||
|
|
@ -140,34 +143,45 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
|
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
|
||||||
"update-during-create-processing": updateDuringCreateProcessingTest,
|
"update-during-create-processing": updateDuringCreateProcessingTest,
|
||||||
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
|
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
|
||||||
"reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest,
|
"reset-clears-recently-deleted-resurrection":
|
||||||
|
resetClearsRecentlyDeletedResurrectionTest,
|
||||||
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
||||||
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
||||||
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
||||||
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
|
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
|
||||||
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
||||||
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
|
"recently-deleted-cleared-on-reconnect":
|
||||||
|
recentlyDeletedClearedOnReconnectTest,
|
||||||
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
|
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
|
||||||
"failed-vfs-move-falls-back": failedVfsMoveFallsBackTest,
|
"failed-vfs-move-falls-back": failedVfsMoveFallsBackTest,
|
||||||
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
||||||
"watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest,
|
"watermark-gap-remote-update-not-recorded":
|
||||||
"queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest,
|
watermarkGapRemoteUpdateNotRecordedTest,
|
||||||
|
"queue-reset-loses-coalesced-local-edit":
|
||||||
|
queueResetLosesCoalescedLocalEditTest,
|
||||||
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
|
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
|
||||||
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
|
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
|
||||||
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
|
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
|
||||||
"rename-pending-create-before-response": renamePendingCreateBeforeResponseTest,
|
"rename-pending-create-before-response":
|
||||||
|
renamePendingCreateBeforeResponseTest,
|
||||||
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
|
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
|
||||||
"online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest,
|
"online-create-rename-concurrent-create-orphan":
|
||||||
|
onlineCreateRenameConcurrentCreateOrphanTest,
|
||||||
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
|
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
|
||||||
"binary-to-text-transition": binaryToTextTransitionTest,
|
"binary-to-text-transition": binaryToTextTransitionTest,
|
||||||
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
|
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
|
||||||
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
|
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
|
||||||
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
|
"coalesce-update-remote-update-data-loss":
|
||||||
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
|
coalesceUpdateRemoteUpdateDataLossTest,
|
||||||
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
|
"coalesced-remote-update-watermark-loss":
|
||||||
|
coalescedRemoteUpdateWatermarkLossTest,
|
||||||
|
"concurrent-delete-during-remote-update":
|
||||||
|
concurrentDeleteDuringRemoteUpdateTest,
|
||||||
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
|
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
|
||||||
"concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest,
|
"concurrent-rename-and-create-at-target-rename-first":
|
||||||
"concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest,
|
concurrentRenameAndCreateAtTargetRenameFirstTest,
|
||||||
|
"concurrent-rename-and-create-at-target-create-first":
|
||||||
|
concurrentRenameAndCreateAtTargetCreateFirstTest,
|
||||||
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
|
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
|
||||||
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
|
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
|
||||||
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
|
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
|
||||||
|
|
@ -176,15 +190,19 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
|
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
|
||||||
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
|
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
|
||||||
"create-during-reconciliation": createDuringReconciliationTest,
|
"create-during-reconciliation": createDuringReconciliationTest,
|
||||||
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
|
"create-merge-preserves-renamed-update":
|
||||||
|
createMergePreservesRenamedUpdateTest,
|
||||||
"create-rename-create-same-path": createRenameCreateSamePathTest,
|
"create-rename-create-same-path": createRenameCreateSamePathTest,
|
||||||
"move-chain-three-files": moveChainThreeFilesTest,
|
"move-chain-three-files": moveChainThreeFilesTest,
|
||||||
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
|
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
|
||||||
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
|
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
|
||||||
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
|
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
|
||||||
"rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest,
|
"rapid-edit-delete-online-convergence":
|
||||||
|
rapidEditDeleteOnlineConvergenceTest,
|
||||||
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
|
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
|
||||||
"online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest,
|
"online-both-create-same-path-deconflict":
|
||||||
"online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
onlineBothCreateSamePathDeconflictTest,
|
||||||
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
|
"online-create-update-while-other-creates-same-path":
|
||||||
|
onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
||||||
|
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import type {
|
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
||||||
TestDefinition,
|
|
||||||
TestResult,
|
|
||||||
TestStep
|
|
||||||
} from "./test-definition";
|
|
||||||
import { DeterministicAgent } from "./deterministic-agent";
|
import { DeterministicAgent } from "./deterministic-agent";
|
||||||
import type { ServerControl } from "./server-control";
|
import type { ServerControl } from "./server-control";
|
||||||
import type { SyncSettings, Logger } from "sync-client";
|
import type { SyncSettings, Logger } from "sync-client";
|
||||||
|
|
@ -113,9 +109,7 @@ export class TestRunner {
|
||||||
// Push before init so cleanup() handles this agent if init fails
|
// Push before init so cleanup() handles this agent if init fails
|
||||||
this.agents.push(agent);
|
this.agents.push(agent);
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
agent.init(
|
agent.init(fetch),
|
||||||
fetch,
|
|
||||||
),
|
|
||||||
AGENT_INIT_TIMEOUT_MS,
|
AGENT_INIT_TIMEOUT_MS,
|
||||||
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
|
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
|
||||||
);
|
);
|
||||||
|
|
@ -276,7 +270,10 @@ export class TestRunner {
|
||||||
verify?: (state: AssertableState) => void
|
verify?: (state: AssertableState) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.info("Asserting all clients are consistent...");
|
this.logger.info("Asserting all clients are consistent...");
|
||||||
assert(this.agents.length >= 2, "Need at least 2 agents for consistency check");
|
assert(
|
||||||
|
this.agents.length >= 2,
|
||||||
|
"Need at least 2 agents for consistency check"
|
||||||
|
);
|
||||||
|
|
||||||
// Snapshot all agents' file states upfront to minimize the window
|
// Snapshot all agents' file states upfront to minimize the window
|
||||||
// where background sync could mutate state between reads.
|
// where background sync could mutate state between reads.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
|
|
@ -23,6 +24,13 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1)
|
||||||
|
.assertFileExists("data.txt")
|
||||||
|
.assertAnyFileContains("client-0", "client-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||||
|
|
@ -35,6 +36,16 @@ export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "header by 0\nmiddle\nfooter by 1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContent(
|
||||||
|
"doc.md",
|
||||||
|
"header by 0\nmiddle\nfooter by 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
|
|
@ -34,7 +35,7 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertFileExists("Chapter.bin")
|
.assertFileExists("Chapter.bin")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createDeleteNoopTest: TestDefinition = {
|
export const createDeleteNoopTest: TestDefinition = {
|
||||||
|
|
@ -16,6 +17,11 @@ export const createDeleteNoopTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileNotExists("temp.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createMergeDeleteTest: TestDefinition = {
|
export const createMergeDeleteTest: TestDefinition = {
|
||||||
|
|
@ -16,12 +17,21 @@ export const createMergeDeleteTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => state.assertFileCount(1).assertContains("A.md", "from-zero", "from-one")
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContains("A.md", "from-zero", "from-one");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "delete", client: 0, path: "A.md" },
|
{ type: "delete", client: 0, path: "A.md" },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0).assertFileNotExists("A.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||||
|
|
@ -31,7 +32,7 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertFileNotExists("A.md")
|
.assertFileNotExists("A.md")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||||
|
|
@ -19,6 +20,13 @@ export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "final version") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContent("doc.md", "final version");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createDuringReconciliationTest: TestDefinition = {
|
export const createDuringReconciliationTest: TestDefinition = {
|
||||||
|
|
@ -37,7 +38,7 @@ export const createDuringReconciliationTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("A.md", "offline A")
|
.assertContent("A.md", "offline A")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||||
|
|
@ -39,6 +40,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertContent("moved.md", "alpha beta extra-update").assertContent("doc.md", "new-content") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertContent("moved.md", "alpha beta extra-update")
|
||||||
|
.assertContent("doc.md", "new-content");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createRenameCreateSamePathTest: TestDefinition = {
|
export const createRenameCreateSamePathTest: TestDefinition = {
|
||||||
|
|
@ -22,7 +23,7 @@ export const createRenameCreateSamePathTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("B.md", "first file")
|
.assertContent("B.md", "first file")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveChainThreeFilesTest: TestDefinition = {
|
export const moveChainThreeFilesTest: TestDefinition = {
|
||||||
|
|
@ -29,7 +30,7 @@ export const moveChainThreeFilesTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("A.md", "was C")
|
.assertContent("A.md", "was C")
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue