mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-01 13:58:18 +00:00
Migrate CLI E2E tests to Node.js 26
Run the CLI subprocess suites and benchmark tools as native TypeScript with node:test, and move them under test/e2e-cli. Update CI and Compose runners, run local smoke tests on Linux, macOS, and Windows, and collect source-mapped CLI coverage with c8.
This commit is contained in:
1 parent
a0987016bb
commit
2433d117c2
91 files changed
+2219
-1615
No files matched your search
+4
-4
@@ -32,7 +32,7 @@ NODE_OPTIONS=--max-old-space-size=3072 npm run test:unit -- --maxWorkers=1
|
||||
| --- | --- |
|
||||
| Multiple-device file conflicts and stale-file protection | [CouchDB procedure below](#multiple-device-conflict-regression-tests) |
|
||||
| Obsidian startup, file watching, persistence, and UI | [Real Obsidian E2E](e2e-obsidian/README.md) |
|
||||
| CLI subprocesses, filesystem workflows, and P2P | [CLI Deno tests](../src/apps/cli/testdeno/test_dev_deno.md) and [test authoring](../src/apps/cli/testdeno/CONTRIBUTING_TESTS.md) |
|
||||
| CLI subprocesses, filesystem workflows, and P2P | [CLI Node.js tests](../test/e2e-cli/test_dev_node.md) and [test authoring](../test/e2e-cli/CONTRIBUTING_TESTS.md) |
|
||||
| WebApp, WebPeer, and browser interoperability | [Browser application tests](browser-apps/README.md) |
|
||||
|
||||
## Community Review checks and CI confirmation
|
||||
@@ -58,8 +58,8 @@ Run the native CLI subprocess suite after building the CLI:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
|
||||
cd src/apps/cli/testdeno
|
||||
deno test -A --no-check test-mirror.ts
|
||||
cd test/e2e-cli
|
||||
node --test --test-concurrency=1 test-mirror.ts
|
||||
```
|
||||
|
||||
The expected result is seven passing steps. These cover storage-only and database-only files, database deletion, an ordinary local edit, incoming database content, an omitted Vault path, and local content with unknown provenance.
|
||||
@@ -75,7 +75,7 @@ NODE_OPTIONS=--max-old-space-size=3072 npm run build:docker --workspace self-hos
|
||||
npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli
|
||||
```
|
||||
|
||||
The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Deno and Bash suites.
|
||||
The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Node.js and Bash suites.
|
||||
|
||||
## Multiple-device conflict regression tests
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:24-slim
|
||||
FROM node:26-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 libjpeg-turbo-progs time \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV DENO_INSTALL=/usr/local
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -20,22 +17,10 @@ RUN npm ci
|
||||
COPY . .
|
||||
RUN NODE_OPTIONS=--max-old-space-size=3072 LIVESYNC_CLI_TEST_SUPPORT=1 npm run build -w self-hosted-livesync-cli
|
||||
|
||||
WORKDIR /workspace/src/apps/cli/testdeno
|
||||
|
||||
RUN deno cache --lock=deno.lock \
|
||||
bench-network-cases.ts \
|
||||
bench-latency-sweep.ts \
|
||||
bench-p2p-split-node.ts \
|
||||
bench-p2p.ts \
|
||||
bench-couchdb.ts \
|
||||
bench-compression.ts \
|
||||
test-p2p-sync.ts \
|
||||
test-p2p-automatic-host.ts \
|
||||
test-p2p-replicator-replacement.ts \
|
||||
test-p2p-relay-disconnect.ts
|
||||
WORKDIR /workspace/test/e2e-cli
|
||||
|
||||
COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench
|
||||
COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e
|
||||
COPY test/e2e-cli/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e
|
||||
RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e
|
||||
|
||||
CMD ["run-livesync-bench"]
|
||||
@@ -74,10 +74,10 @@ services:
|
||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||
BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local}
|
||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_COMPRESSION_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_COMPRESSION_RESULT_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_COMPRESSION_REPEAT_COUNT: ${BENCH_COMPRESSION_REPEAT_COUNT:-1}
|
||||
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_SWEEP_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300}
|
||||
BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true}
|
||||
BENCH_COUCHDB_MANAGED: "false"
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
TURN_SERVERS: ${TURN_SERVERS:-none}
|
||||
LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1}
|
||||
volumes:
|
||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||
|
||||
couchdb-shim:
|
||||
build:
|
||||
@@ -156,8 +156,8 @@ services:
|
||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||
BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi}
|
||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_SWEEP_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_COUCHDB_MANAGED: "false"
|
||||
BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984
|
||||
BENCH_SHIM_COUCHDB_URI: http://couchdb-shim:5984
|
||||
@@ -173,7 +173,7 @@ services:
|
||||
BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||
volumes:
|
||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||
|
||||
p2p-signalling-shim:
|
||||
build:
|
||||
@@ -219,7 +219,7 @@ services:
|
||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||
BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi}
|
||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
||||
BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/
|
||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
||||
@@ -232,7 +232,7 @@ services:
|
||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||
volumes:
|
||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||
|
||||
p2p-split-host:
|
||||
build:
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
BENCH_P2P_SPLIT_ROLE: host
|
||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
||||
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_SPLIT_RESULT_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_RELAY: ws://nostr-relay:7777/
|
||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||
@@ -276,7 +276,7 @@ services:
|
||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||
volumes:
|
||||
- p2p-split-work:/p2p-work
|
||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||
|
||||
p2p-split-client:
|
||||
build:
|
||||
@@ -296,7 +296,7 @@ services:
|
||||
BENCH_P2P_SPLIT_ROLE: client
|
||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
||||
BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
||||
BENCH_SPLIT_RESULT_ROOT: /workspace/test/e2e-cli/bench-results
|
||||
BENCH_RELAY: ws://nostr-relay:7777/
|
||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||
@@ -317,7 +317,7 @@ services:
|
||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||
volumes:
|
||||
- p2p-split-work:/p2p-work
|
||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
||||
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||
|
||||
netem-smoke:
|
||||
build:
|
||||
|
||||
@@ -3,16 +3,16 @@ set -eu
|
||||
|
||||
case "${BENCH_COMMAND:-cases}" in
|
||||
cases)
|
||||
exec deno task bench:cases
|
||||
exec node run-task.ts bench:cases
|
||||
;;
|
||||
latency-sweep)
|
||||
exec deno task bench:latency-sweep
|
||||
exec node run-task.ts bench:latency-sweep
|
||||
;;
|
||||
compression)
|
||||
exec deno task bench:compression
|
||||
exec node run-task.ts bench:compression
|
||||
;;
|
||||
p2p-split-node)
|
||||
exec deno task bench:p2p-split-node
|
||||
exec node run-task.ts bench:p2p-split-node
|
||||
;;
|
||||
*)
|
||||
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
hostname=http://127.0.0.1:5989/
|
||||
dbname=livesync-test-db-ci
|
||||
username=admin
|
||||
password=testpassword
|
||||
minioEndpoint=http://127.0.0.1:9000
|
||||
accessKey=minioadmin
|
||||
secretKey=minioadmin
|
||||
bucketName=livesync-test-bucket-ci
|
||||
LIVESYNC_TEST_TEE=1
|
||||
@@ -0,0 +1,70 @@
|
||||
# Writing CLI Tests with Node.js
|
||||
|
||||
Add CLI end-to-end tests under `test/e2e-cli/`. This suite uses Node.js 26 native TypeScript support and the built-in `node:test` runner so that local filesystem and subprocess scenarios can run on Windows, macOS, and Linux.
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep one scenario per file when practical.
|
||||
- Reuse helpers from `helpers/` rather than duplicating process, Docker, or settings logic.
|
||||
- Prefer deterministic data over random inputs unless randomness is explicitly required.
|
||||
- Ensure every test can clean up automatically.
|
||||
- Keep assertions actionable with clear failure messages.
|
||||
- Keep test concurrency at one. Tests share the built CLI and may start local services.
|
||||
- Use TypeScript syntax which Node can strip without compilation; `test/e2e-cli/tsconfig.json` checks this boundary.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```text
|
||||
test/e2e-cli/
|
||||
helpers/
|
||||
backgroundCli.ts
|
||||
cli.ts
|
||||
docker.ts
|
||||
env.ts
|
||||
p2p.ts
|
||||
settings.ts
|
||||
temp.ts
|
||||
test-*.ts
|
||||
run-task.ts
|
||||
tasks.json
|
||||
ci-tasks.json
|
||||
```
|
||||
|
||||
## Test file naming and assertions
|
||||
|
||||
Use `test-<feature>.ts`, aligned with existing CLI test names where practical. Import the built-in test and assertion APIs: `test` and `describe` from `node:test`, and assertions from `node:assert/strict`. For example:
|
||||
|
||||
```ts
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
test("feature: behaviour", async (t) => {
|
||||
await t.test("preserves the expected result", () => {
|
||||
assert.equal(actualValue, expectedValue);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Core helper usage
|
||||
|
||||
- `runCli(...)` and `runCliOrFail(...)` execute the CLI and return or validate its result.
|
||||
- `runCliWithInputOrFail(input, ...)` supports `put` and other stdin-driven commands.
|
||||
- `TempDir` and `await using` provide automatic temporary-directory cleanup.
|
||||
- Settings helpers create baseline files and apply CouchDB or P2P configuration.
|
||||
- Docker helpers start, initialise, and stop local CouchDB, RustFS, and relay services when a test needs them.
|
||||
- `startCliInBackground(...)` manages long-running host commands such as `p2p-host`.
|
||||
|
||||
Prefer `try`/`finally` when a test needs explicit cleanup in addition to registered test hooks. For network-sensitive commands, use `LIVESYNC_CLI_RETRY` during debugging. Keep Docker container reuse disabled by default unless debugging.
|
||||
|
||||
## Task registration
|
||||
|
||||
Add each supported task name to `tasks.json`. A task record uses one of these forms:
|
||||
|
||||
- `{ "test": ["test-file.ts"] }`, optionally with a `filter` regular expression for named subtests.
|
||||
- `{ "script": "script.ts" }` for a TypeScript script.
|
||||
- `{ "tasks": ["task-name"] }` for an ordered task group.
|
||||
- `{ "command": "...", "args": [...] }` only for the retained legacy benchmark shell scripts.
|
||||
|
||||
Add a task name to `ci-tasks.json` only when it belongs in the standard service-backed CI suite. That file is authoritative for both `run-ci-suite.ts` and the automatic CI matrix. Keep the local `test:local` group service-free so it remains suitable for Windows, macOS, and Linux smoke runs.
|
||||
|
||||
Use `npm run test:e2e:task --workspace self-hosted-livesync-cli -- <task-name>` from the repository root to run one task after building the CLI. The standard suite and canonical P2P suite use `npm run test:e2e:cli` and `npm run test:e2e:cli:p2p`, respectively. P2P network scenarios use Compose because host networking and WebRTC candidate selection are not reproducible across environments; individual host-direct P2P tasks are for explicit diagnostics.
|
||||
@@ -0,0 +1,606 @@
|
||||
import { REPOSITORY_ROOT } from "./helpers/paths.ts";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { assertFilesEqual } from "./helpers/cli.ts";
|
||||
import { runMeasuredCliOrFail, type CliProcessMeasurement } from "./helpers/measuredCli.ts";
|
||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
import {
|
||||
createCompressionBenchmarkDataset,
|
||||
type CompressionDataset,
|
||||
type CompressionDatasetEntry,
|
||||
} from "./helpers/compressionDataset.ts";
|
||||
import { computeDatasetDigestSha256 } from "./helpers/benchmarkVerification.ts";
|
||||
import { startCouchdbProxy, type CouchdbProxyCounters } from "./bench-couchdb.ts";
|
||||
import type { DatasetKind } from "./helpers/dataset.ts";
|
||||
|
||||
type CompressionCondition = {
|
||||
name: string;
|
||||
encrypt: boolean;
|
||||
enableCompression: boolean;
|
||||
};
|
||||
|
||||
type CouchDbSizes = {
|
||||
file: number;
|
||||
external: number;
|
||||
active: number;
|
||||
};
|
||||
|
||||
type PerKindRemoteMeasurement = {
|
||||
sourceFiles: number;
|
||||
sourceBytes: number;
|
||||
mappedFiles: number;
|
||||
uniqueReferencedChunks: number;
|
||||
storedChunkDataBytes: number;
|
||||
storedChunkJsonBytes: number;
|
||||
};
|
||||
|
||||
type RemoteMeasurement = {
|
||||
couchdbSizes: CouchDbSizes;
|
||||
documentCount: number;
|
||||
chunkDocumentCount: number;
|
||||
metadataDocumentCount: number;
|
||||
compressedMarkerCount: number;
|
||||
encryptedChunkCount: number;
|
||||
storedChunkDataBytes: number;
|
||||
storedChunkJsonBytes: number;
|
||||
perKind: Record<DatasetKind, PerKindRemoteMeasurement>;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
condition: CompressionCondition;
|
||||
repeatIndex: number;
|
||||
executionOrder: number;
|
||||
databaseName: string;
|
||||
datasetDigestSha256: string;
|
||||
dataset: {
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
filesByKind: CompressionDataset["filesByKind"];
|
||||
bytesByKind: CompressionDataset["bytesByKind"];
|
||||
jpegGenerator: string;
|
||||
};
|
||||
effectiveSettings: Record<string, unknown>;
|
||||
mirror: CliProcessMeasurement;
|
||||
upload: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
download: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
materialisation: CliProcessMeasurement & { http: CouchdbProxyCounters };
|
||||
verification: {
|
||||
verifiedFiles: number;
|
||||
complete: boolean;
|
||||
};
|
||||
remote: RemoteMeasurement;
|
||||
};
|
||||
|
||||
const CONDITIONS: CompressionCondition[] = [
|
||||
{ name: "plain", encrypt: false, enableCompression: false },
|
||||
{ name: "plain-compressed", encrypt: false, enableCompression: true },
|
||||
{ name: "e2ee", encrypt: true, enableCompression: false },
|
||||
{ name: "e2ee-compressed", encrypt: true, enableCompression: true },
|
||||
];
|
||||
|
||||
const DATASET_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
||||
const COMPRESSED_MARKER = "\u000eLZ\u001d";
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvPositiveInteger(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, got '${raw}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readEnvPositiveNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be positive, got '${raw}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readEnvBoolean(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
return /^(1|true|yes|on)$/i.test(raw);
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[middle];
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
function deltaPercent(enabled: number, disabled: number): number | null {
|
||||
if (disabled === 0) return null;
|
||||
return rounded(((enabled - disabled) / disabled) * 100);
|
||||
}
|
||||
|
||||
function reductionPercent(enabled: number, disabled: number): number | null {
|
||||
if (disabled === 0) return null;
|
||||
return rounded((1 - enabled / disabled) * 100);
|
||||
}
|
||||
|
||||
function basicAuth(user: string, password: string): string {
|
||||
return `Basic ${btoa(`${user}:${password}`)}`;
|
||||
}
|
||||
|
||||
async function couchRequest(
|
||||
baseUri: string,
|
||||
user: string,
|
||||
password: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
allowedStatuses: number[] = []
|
||||
): Promise<Response> {
|
||||
const response = await fetch(`${baseUri.replace(/\/$/, "")}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: basicAuth(user, password),
|
||||
...(init.method === "POST" || init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok && !allowedStatuses.includes(response.status)) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function deleteDatabase(baseUri: string, user: string, password: string, databaseName: string): Promise<void> {
|
||||
const response = await couchRequest(
|
||||
baseUri,
|
||||
user,
|
||||
password,
|
||||
`/${encodeURIComponent(databaseName)}`,
|
||||
{ method: "DELETE" },
|
||||
[404]
|
||||
);
|
||||
await response.body?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
function blankPerKind(dataset: CompressionDataset): Record<DatasetKind, PerKindRemoteMeasurement> {
|
||||
return Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [
|
||||
kind,
|
||||
{
|
||||
sourceFiles: dataset.filesByKind[kind],
|
||||
sourceBytes: dataset.bytesByKind[kind],
|
||||
mappedFiles: 0,
|
||||
uniqueReferencedChunks: 0,
|
||||
storedChunkDataBytes: 0,
|
||||
storedChunkJsonBytes: 0,
|
||||
},
|
||||
])
|
||||
) as Record<DatasetKind, PerKindRemoteMeasurement>;
|
||||
}
|
||||
|
||||
function findDatasetEntry(path: unknown, entries: CompressionDatasetEntry[]): CompressionDatasetEntry | undefined {
|
||||
if (typeof path !== "string") return undefined;
|
||||
return entries.find((entry) => path === entry.relativePath || path.endsWith(`/${entry.relativePath}`));
|
||||
}
|
||||
|
||||
async function inspectRemoteDatabase(options: {
|
||||
baseUri: string;
|
||||
user: string;
|
||||
password: string;
|
||||
databaseName: string;
|
||||
dataset: CompressionDataset;
|
||||
}): Promise<RemoteMeasurement> {
|
||||
const dbPath = `/${encodeURIComponent(options.databaseName)}`;
|
||||
await couchRequest(options.baseUri, options.user, options.password, `${dbPath}/_ensure_full_commit`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const [info, allDocs] = await Promise.all([
|
||||
couchRequest(options.baseUri, options.user, options.password, dbPath).then((response) => response.json()),
|
||||
couchRequest(options.baseUri, options.user, options.password, `${dbPath}/_all_docs?include_docs=true`).then(
|
||||
(response) => response.json()
|
||||
),
|
||||
]);
|
||||
const rows = (allDocs as { rows?: Array<{ doc?: Record<string, unknown> }> }).rows ?? [];
|
||||
const docs = rows.flatMap((row) => (row.doc ? [row.doc] : []));
|
||||
const docsById = new Map(docs.flatMap((doc) => (typeof doc._id === "string" ? [[doc._id, doc] as const] : [])));
|
||||
const metadataDocs = docs.filter((doc) => Array.isArray(doc.children) && typeof doc.path === "string");
|
||||
const chunkDocs = docs.filter(
|
||||
(doc) =>
|
||||
(doc.type === "leaf" || doc.type === "chunkpack") &&
|
||||
typeof doc.data === "string" &&
|
||||
typeof doc._id === "string"
|
||||
);
|
||||
const perKind = blankPerKind(options.dataset);
|
||||
const chunkIdsByKind = new Map(DATASET_KINDS.map((kind) => [kind, new Set<string>()] as const));
|
||||
|
||||
for (const doc of metadataDocs) {
|
||||
const entry = findDatasetEntry(doc.path, options.dataset.entries);
|
||||
if (!entry) continue;
|
||||
perKind[entry.kind].mappedFiles += 1;
|
||||
for (const child of doc.children as unknown[]) {
|
||||
if (typeof child === "string") chunkIdsByKind.get(entry.kind)!.add(child);
|
||||
}
|
||||
}
|
||||
for (const kind of DATASET_KINDS) {
|
||||
const chunkIds = chunkIdsByKind.get(kind)!;
|
||||
perKind[kind].uniqueReferencedChunks = chunkIds.size;
|
||||
for (const chunkId of chunkIds) {
|
||||
const chunk = docsById.get(chunkId);
|
||||
if (!chunk || typeof chunk.data !== "string") continue;
|
||||
perKind[kind].storedChunkDataBytes += byteLength(chunk.data);
|
||||
perKind[kind].storedChunkJsonBytes += byteLength(JSON.stringify(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
const sizeInfo = (info as { sizes?: Partial<CouchDbSizes>; doc_count?: number }).sizes ?? {};
|
||||
return {
|
||||
couchdbSizes: {
|
||||
file: sizeInfo.file ?? 0,
|
||||
external: sizeInfo.external ?? 0,
|
||||
active: sizeInfo.active ?? 0,
|
||||
},
|
||||
documentCount: (info as { doc_count?: number }).doc_count ?? docs.length,
|
||||
chunkDocumentCount: chunkDocs.length,
|
||||
metadataDocumentCount: metadataDocs.length,
|
||||
compressedMarkerCount: chunkDocs.filter(
|
||||
(doc) => typeof doc.data === "string" && doc.data.startsWith(COMPRESSED_MARKER)
|
||||
).length,
|
||||
encryptedChunkCount: chunkDocs.filter((doc) => doc.e_ === true).length,
|
||||
storedChunkDataBytes: chunkDocs.reduce(
|
||||
(sum, doc) => sum + (typeof doc.data === "string" ? byteLength(doc.data) : 0),
|
||||
0
|
||||
),
|
||||
storedChunkJsonBytes: chunkDocs.reduce((sum, doc) => sum + byteLength(JSON.stringify(doc)), 0),
|
||||
perKind,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyDataset(
|
||||
workDir: TempDir,
|
||||
vaultB: string,
|
||||
settingsB: string,
|
||||
entries: CompressionDatasetEntry[]
|
||||
): Promise<CliProcessMeasurement> {
|
||||
const started = performance.now();
|
||||
const measurements: CliProcessMeasurement[] = [];
|
||||
for (const entry of entries) {
|
||||
const pulledPath = workDir.join(`verify-${entry.kind}-${entry.relativePath.split("/").at(-1)}`);
|
||||
measurements.push(
|
||||
await runMeasuredCliOrFail(vaultB, "--settings", settingsB, "pull", entry.relativePath, pulledPath)
|
||||
);
|
||||
await assertFilesEqual(entry.absolutePath, pulledPath, `compression benchmark mismatch: ${entry.relativePath}`);
|
||||
}
|
||||
const elapsedMs = performance.now() - started;
|
||||
const userCpuMs = measurements.reduce((sum, measurement) => sum + measurement.userCpuMs, 0);
|
||||
const systemCpuMs = measurements.reduce((sum, measurement) => sum + measurement.systemCpuMs, 0);
|
||||
const totalCpuMs = userCpuMs + systemCpuMs;
|
||||
return {
|
||||
elapsedMs: rounded(elapsedMs),
|
||||
userCpuMs: rounded(userCpuMs),
|
||||
systemCpuMs: rounded(systemCpuMs),
|
||||
totalCpuMs: rounded(totalCpuMs),
|
||||
cpuToWallRatio: rounded(totalCpuMs / elapsedMs),
|
||||
maxResidentSetKiB: Math.max(...measurements.map((measurement) => measurement.maxResidentSetKiB)),
|
||||
};
|
||||
}
|
||||
|
||||
function summariseResults(results: RunResult[]) {
|
||||
const byCondition = Object.fromEntries(
|
||||
CONDITIONS.map((condition) => {
|
||||
const runs = results.filter((result) => result.condition.name === condition.name);
|
||||
return [
|
||||
condition.name,
|
||||
{
|
||||
repeats: runs.length,
|
||||
remoteStoredChunkDataBytesMedian: median(runs.map((run) => run.remote.storedChunkDataBytes)),
|
||||
couchdbExternalBytesMedian: median(runs.map((run) => run.remote.couchdbSizes.external)),
|
||||
couchdbFileBytesMedian: median(runs.map((run) => run.remote.couchdbSizes.file)),
|
||||
uploadRequestBodyBytesMedian: median(runs.map((run) => run.upload.http.requestBodyBytes)),
|
||||
uploadResponseBodyBytesMedian: median(runs.map((run) => run.upload.http.responseBodyBytes)),
|
||||
downloadRequestBodyBytesMedian: median(runs.map((run) => run.download.http.requestBodyBytes)),
|
||||
downloadResponseBodyBytesMedian: median(runs.map((run) => run.download.http.responseBodyBytes)),
|
||||
uploadElapsedMsMedian: median(runs.map((run) => run.upload.elapsedMs)),
|
||||
uploadCpuMsMedian: median(runs.map((run) => run.upload.totalCpuMs)),
|
||||
downloadElapsedMsMedian: median(runs.map((run) => run.download.elapsedMs)),
|
||||
downloadCpuMsMedian: median(runs.map((run) => run.download.totalCpuMs)),
|
||||
materialisationElapsedMsMedian: median(runs.map((run) => run.materialisation.elapsedMs)),
|
||||
materialisationCpuMsMedian: median(runs.map((run) => run.materialisation.totalCpuMs)),
|
||||
materialisationResponseBodyBytesMedian: median(
|
||||
runs.map((run) => run.materialisation.http.responseBodyBytes)
|
||||
),
|
||||
completeDownloadResponseBodyBytesMedian: median(
|
||||
runs.map(
|
||||
(run) => run.download.http.responseBodyBytes + run.materialisation.http.responseBodyBytes
|
||||
)
|
||||
),
|
||||
completeDownloadElapsedMsMedian: median(
|
||||
runs.map((run) => run.download.elapsedMs + run.materialisation.elapsedMs)
|
||||
),
|
||||
completeDownloadCpuMsMedian: median(
|
||||
runs.map((run) => run.download.totalCpuMs + run.materialisation.totalCpuMs)
|
||||
),
|
||||
maxResidentSetKiBMedian: median(
|
||||
runs.map((run) =>
|
||||
Math.max(
|
||||
run.upload.maxResidentSetKiB,
|
||||
run.download.maxResidentSetKiB,
|
||||
run.materialisation.maxResidentSetKiB
|
||||
)
|
||||
)
|
||||
),
|
||||
perKindStoredChunkDataBytesMedian: Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [
|
||||
kind,
|
||||
median(runs.map((run) => run.remote.perKind[kind].storedChunkDataBytes)),
|
||||
])
|
||||
),
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const comparisons = [false, true].map((encrypt) => {
|
||||
const disabledName = encrypt ? "e2ee" : "plain";
|
||||
const enabledName = encrypt ? "e2ee-compressed" : "plain-compressed";
|
||||
const disabled = byCondition[disabledName] as Record<string, unknown>;
|
||||
const enabled = byCondition[enabledName] as Record<string, unknown>;
|
||||
const disabledPerKind = disabled.perKindStoredChunkDataBytesMedian as Record<DatasetKind, number>;
|
||||
const enabledPerKind = enabled.perKindStoredChunkDataBytesMedian as Record<DatasetKind, number>;
|
||||
return {
|
||||
encrypt,
|
||||
disabledCondition: disabledName,
|
||||
enabledCondition: enabledName,
|
||||
storedChunkDataReductionPercent: reductionPercent(
|
||||
enabled.remoteStoredChunkDataBytesMedian as number,
|
||||
disabled.remoteStoredChunkDataBytesMedian as number
|
||||
),
|
||||
couchdbExternalReductionPercent: reductionPercent(
|
||||
enabled.couchdbExternalBytesMedian as number,
|
||||
disabled.couchdbExternalBytesMedian as number
|
||||
),
|
||||
couchdbFileReductionPercent: reductionPercent(
|
||||
enabled.couchdbFileBytesMedian as number,
|
||||
disabled.couchdbFileBytesMedian as number
|
||||
),
|
||||
uploadRequestBodyReductionPercent: reductionPercent(
|
||||
enabled.uploadRequestBodyBytesMedian as number,
|
||||
disabled.uploadRequestBodyBytesMedian as number
|
||||
),
|
||||
completeDownloadResponseBodyReductionPercent: reductionPercent(
|
||||
enabled.completeDownloadResponseBodyBytesMedian as number,
|
||||
disabled.completeDownloadResponseBodyBytesMedian as number
|
||||
),
|
||||
uploadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.uploadElapsedMsMedian as number,
|
||||
disabled.uploadElapsedMsMedian as number
|
||||
),
|
||||
uploadCpuDeltaPercent: deltaPercent(
|
||||
enabled.uploadCpuMsMedian as number,
|
||||
disabled.uploadCpuMsMedian as number
|
||||
),
|
||||
downloadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.downloadElapsedMsMedian as number,
|
||||
disabled.downloadElapsedMsMedian as number
|
||||
),
|
||||
downloadCpuDeltaPercent: deltaPercent(
|
||||
enabled.downloadCpuMsMedian as number,
|
||||
disabled.downloadCpuMsMedian as number
|
||||
),
|
||||
completeDownloadElapsedDeltaPercent: deltaPercent(
|
||||
enabled.completeDownloadElapsedMsMedian as number,
|
||||
disabled.completeDownloadElapsedMsMedian as number
|
||||
),
|
||||
completeDownloadCpuDeltaPercent: deltaPercent(
|
||||
enabled.completeDownloadCpuMsMedian as number,
|
||||
disabled.completeDownloadCpuMsMedian as number
|
||||
),
|
||||
perKindStoredChunkDataReductionPercent: Object.fromEntries(
|
||||
DATASET_KINDS.map((kind) => [kind, reductionPercent(enabledPerKind[kind], disabledPerKind[kind])])
|
||||
),
|
||||
};
|
||||
});
|
||||
return { byCondition, comparisons };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const backendUri = readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989");
|
||||
const proxyUri = readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989");
|
||||
const user = readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin"));
|
||||
const password = readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password"));
|
||||
const databasePrefix = readEnvString("BENCH_COUCHDB_DBNAME", `compression-bench-${Date.now()}`);
|
||||
const repeatCount = readEnvPositiveInteger("BENCH_COMPRESSION_REPEAT_COUNT", 1);
|
||||
const requestedRttMs = readEnvPositiveNumber("BENCH_COUCHDB_RTT_MS", 1);
|
||||
const managedCouchdb = readEnvBoolean("BENCH_COUCHDB_MANAGED", true);
|
||||
const passphrase = readEnvString("BENCH_PASSPHRASE", "compression-benchmark-passphrase");
|
||||
const resultRoot = readEnvString("BENCH_COMPRESSION_RESULT_ROOT", "bench-results");
|
||||
const resultPath =
|
||||
process.env.BENCH_COMPRESSION_RESULT_JSON?.trim() ||
|
||||
join(resultRoot, `compression-${new Date().toISOString().replaceAll(":", "-")}.json`);
|
||||
const createdDatabases = new Set<string>();
|
||||
const results: RunResult[] = [];
|
||||
let managedStarted = false;
|
||||
|
||||
await mkdir(resultRoot, { recursive: true });
|
||||
const proxy = await startCouchdbProxy({ backendUri, proxyUri, requestedRttMs });
|
||||
|
||||
try {
|
||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||
const rotation = (repeatIndex - 1) % CONDITIONS.length;
|
||||
const orderedConditions = [...CONDITIONS.slice(rotation), ...CONDITIONS.slice(0, rotation)];
|
||||
for (const [executionOffset, condition] of orderedConditions.entries()) {
|
||||
const databaseName = `${databasePrefix}-${repeatIndex}-${condition.name}`.toLowerCase();
|
||||
if (managedCouchdb && !managedStarted) {
|
||||
await startCouchdb(backendUri, user, password, databaseName);
|
||||
managedStarted = true;
|
||||
} else {
|
||||
await createCouchdbDatabase(backendUri, user, password, databaseName);
|
||||
}
|
||||
createdDatabases.add(databaseName);
|
||||
|
||||
await using workDir = await TempDir.create(`livesync-compression-${condition.name}`);
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
await Promise.all(
|
||||
[settingsA, settingsB].map((settingsFile) =>
|
||||
applyRemoteSyncSettings(settingsFile, {
|
||||
remoteType: "COUCHDB",
|
||||
couchdbUri: proxyUri,
|
||||
couchdbUser: user,
|
||||
couchdbPassword: password,
|
||||
couchdbDbname: databaseName,
|
||||
encrypt: condition.encrypt,
|
||||
passphrase,
|
||||
enableCompression: condition.enableCompression,
|
||||
usePathObfuscation: false,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const dataset = await createCompressionBenchmarkDataset({ rootDir: vaultA });
|
||||
const datasetDigestSha256 = await computeDatasetDigestSha256(dataset.entries);
|
||||
const mirror = await runMeasuredCliOrFail(vaultA, "--settings", settingsA, "mirror");
|
||||
|
||||
proxy.resetCounters();
|
||||
const uploadMeasurement = await runMeasuredCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
const upload = { ...uploadMeasurement, http: proxy.snapshotCounters() };
|
||||
|
||||
proxy.resetCounters();
|
||||
const downloadMeasurement = await runMeasuredCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const download = { ...downloadMeasurement, http: proxy.snapshotCounters() };
|
||||
|
||||
proxy.resetCounters();
|
||||
const materialisationMeasurement = await verifyDataset(workDir, vaultB, settingsB, dataset.entries);
|
||||
const materialisation = {
|
||||
...materialisationMeasurement,
|
||||
http: proxy.snapshotCounters(),
|
||||
};
|
||||
const remote = await inspectRemoteDatabase({
|
||||
baseUri: backendUri,
|
||||
user,
|
||||
password,
|
||||
databaseName,
|
||||
dataset,
|
||||
});
|
||||
const settings = JSON.parse(await readFile(settingsA, "utf8")) as Record<string, unknown>;
|
||||
const effectiveSettings = Object.fromEntries(
|
||||
[
|
||||
"encrypt",
|
||||
"enableCompression",
|
||||
"E2EEAlgorithm",
|
||||
"usePathObfuscation",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"minimumChunkSize",
|
||||
"hashAlg",
|
||||
].map((key) => [key, settings[key]])
|
||||
);
|
||||
|
||||
results.push({
|
||||
condition,
|
||||
repeatIndex,
|
||||
executionOrder: executionOffset + 1,
|
||||
databaseName,
|
||||
datasetDigestSha256,
|
||||
dataset: {
|
||||
totalFiles: dataset.totalFiles,
|
||||
totalBytes: dataset.totalBytes,
|
||||
filesByKind: dataset.filesByKind,
|
||||
bytesByKind: dataset.bytesByKind,
|
||||
jpegGenerator: dataset.jpegGenerator,
|
||||
},
|
||||
effectiveSettings,
|
||||
mirror,
|
||||
upload,
|
||||
download,
|
||||
materialisation,
|
||||
verification: { verifiedFiles: dataset.entries.length, complete: true },
|
||||
remote,
|
||||
});
|
||||
await deleteDatabase(backendUri, user, password, databaseName);
|
||||
createdDatabases.delete(databaseName);
|
||||
console.error(
|
||||
`[Compression benchmark] repeat ${repeatIndex}/${repeatCount} ${condition.name}: ` +
|
||||
`${dataset.totalFiles} files, ${remote.chunkDocumentCount} chunks, ` +
|
||||
`${remote.storedChunkDataBytes} stored chunk-data bytes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
mode: "couchdb-cli-compression-benchmark",
|
||||
generatedAt: new Date().toISOString(),
|
||||
commonlibVersion: (
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(REPOSITORY_ROOT, "node_modules/@vrtmrz/livesync-commonlib/package.json"),
|
||||
"utf8"
|
||||
)
|
||||
) as { version: string }
|
||||
).version,
|
||||
couchdbVersion: (
|
||||
(await couchRequest(backendUri, user, password, "/").then((response) => response.json())) as {
|
||||
version?: string;
|
||||
}
|
||||
).version,
|
||||
compressionImplementation: "Commonlib replicationFilter using fflate level 8 before E2EE V2",
|
||||
chunkingImplementation: "LiveSync CLI mirror using the effective settings recorded for each run",
|
||||
requestedRttMs,
|
||||
httpByteScope:
|
||||
"Decoded HTTP request and response body bytes observed by the local proxy; headers are excluded.",
|
||||
limitations: [
|
||||
"Synthetic JPEGs exercise a deterministic image-like fixture but are not a photographic corpus.",
|
||||
"PNG, Markdown, JSON, and TypeScript inputs are current repository files and therefore change with the source tree.",
|
||||
"Wall and CPU times include CLI process start-up; compare repeated medians rather than treating one run as a universal result.",
|
||||
"Full materialisation starts one CLI process per file and can repeat lazy chunk fetches; treat it as a CLI workflow measurement rather than a raw download lower bound.",
|
||||
"The benchmark uses a local CouchDB and a fixed latency proxy, not a contended production server or a real WAN.",
|
||||
"Path obfuscation is explicitly disabled so raw metadata can be mapped back to file kinds.",
|
||||
],
|
||||
repeatCount,
|
||||
conditions: CONDITIONS,
|
||||
summary: summariseResults(results),
|
||||
runs: results,
|
||||
};
|
||||
await writeFile(resultPath, JSON.stringify(output, null, 2));
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
console.error(`[Compression benchmark] wrote ${resultPath}`);
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
for (const databaseName of createdDatabases) {
|
||||
await deleteDatabase(backendUri, user, password, databaseName).catch((error) => console.error(error));
|
||||
}
|
||||
if (managedCouchdb && managedStarted) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error("[Compression benchmark fatal]", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import {
|
||||
applyRemoteSyncSettings,
|
||||
initSettingsFile,
|
||||
} from "./helpers/settings.ts";
|
||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||
import {
|
||||
createCouchdbDatabase,
|
||||
startCouchdb,
|
||||
stopCouchdb,
|
||||
} from "./helpers/docker.ts";
|
||||
import {
|
||||
createDeterministicDataset,
|
||||
} from "./helpers/dataset.ts";
|
||||
import {
|
||||
type BenchmarkVerificationMode,
|
||||
parseBenchmarkVerificationMode,
|
||||
verifyBenchmarkDataset,
|
||||
} from "./helpers/benchmarkVerification.ts";
|
||||
|
||||
type BenchmarkConfig = {
|
||||
caseName: string;
|
||||
couchdbBackendUri: string;
|
||||
couchdbProxyUri: string;
|
||||
couchdbUser: string;
|
||||
couchdbPassword: string;
|
||||
couchdbDbname: string;
|
||||
datasetDirName: string;
|
||||
datasetSeed: string;
|
||||
mdFileCount: number;
|
||||
mdMinSizeBytes: number;
|
||||
mdMaxSizeBytes: number;
|
||||
binFileCount: number;
|
||||
binSizeBytes: number;
|
||||
syncTimeoutSeconds: number;
|
||||
requestedRttMs: number;
|
||||
passphrase: string;
|
||||
encrypt: boolean;
|
||||
managedCouchdb: boolean;
|
||||
simulationTier: string;
|
||||
networkProfile: string;
|
||||
networkModel: string;
|
||||
measurementScope: string;
|
||||
limitations: string[];
|
||||
verificationMode: BenchmarkVerificationMode;
|
||||
repeatIndex: number;
|
||||
repeatCount: number;
|
||||
};
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive number, got '${raw}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readEnvBool(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
return /^(1|true|yes|on)$/i.test(raw.trim());
|
||||
}
|
||||
|
||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === "string")
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to pipe-separated parsing for hand-written invocations.
|
||||
}
|
||||
|
||||
return raw
|
||||
.split("|")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
function formatMs(value: number): string {
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
const kib = value / 1024;
|
||||
if (kib < 1024) {
|
||||
return `${kib.toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(kib / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function buildConfig(): BenchmarkConfig {
|
||||
return {
|
||||
caseName: readEnvString("BENCH_CASE", "couchdb-baseline"),
|
||||
couchdbBackendUri: readEnvString(
|
||||
"BENCH_COUCHDB_BACKEND_URI",
|
||||
"http://127.0.0.1:5989",
|
||||
),
|
||||
couchdbProxyUri: readEnvString(
|
||||
"BENCH_COUCHDB_URI",
|
||||
"http://127.0.0.1:15989",
|
||||
),
|
||||
couchdbUser: readEnvString(
|
||||
"BENCH_COUCHDB_USER",
|
||||
readEnvString("username", "admin"),
|
||||
),
|
||||
couchdbPassword: readEnvString(
|
||||
"BENCH_COUCHDB_PASSWORD",
|
||||
readEnvString("password", "password"),
|
||||
),
|
||||
couchdbDbname: readEnvString(
|
||||
"BENCH_COUCHDB_DBNAME",
|
||||
`bench-couchdb-${Date.now()}`,
|
||||
),
|
||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
||||
mdMinSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
|
||||
),
|
||||
mdMaxSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
|
||||
),
|
||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
||||
binSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
|
||||
),
|
||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
||||
requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)),
|
||||
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
||||
encrypt: readEnvBool("BENCH_ENCRYPT", true),
|
||||
managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true),
|
||||
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
|
||||
networkProfile: readEnvString(
|
||||
"BENCH_NETWORK_PROFILE",
|
||||
"http-latency-proxy",
|
||||
),
|
||||
networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"),
|
||||
measurementScope: readEnvString(
|
||||
"BENCH_MEASUREMENT_SCOPE",
|
||||
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path.",
|
||||
),
|
||||
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
|
||||
"This benchmark result is scoped to the configured dataset, remote store, and network model.",
|
||||
]),
|
||||
verificationMode: parseBenchmarkVerificationMode(
|
||||
process.env.BENCH_VERIFY_MODE,
|
||||
),
|
||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalResultPath(): string | undefined {
|
||||
const raw = process.env.BENCH_RESULT_JSON?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export type CouchdbProxyHandle = {
|
||||
stop: () => Promise<void>;
|
||||
resetCounters: () => void;
|
||||
snapshotCounters: () => CouchdbProxyCounters;
|
||||
applied: boolean;
|
||||
note: string;
|
||||
directionalDelayMs: number;
|
||||
};
|
||||
|
||||
export type CouchdbProxyCounters = {
|
||||
requestCount: number;
|
||||
requestBodyBytes: number;
|
||||
responseBodyBytes: number;
|
||||
};
|
||||
|
||||
export async function startCouchdbProxy(
|
||||
options: {
|
||||
backendUri: string;
|
||||
proxyUri: string;
|
||||
requestedRttMs: number;
|
||||
delay?: (milliseconds: number) => Promise<void>;
|
||||
},
|
||||
): Promise<CouchdbProxyHandle> {
|
||||
const backend = new URL(options.backendUri);
|
||||
const proxy = new URL(options.proxyUri);
|
||||
const halfDelayMs = options.requestedRttMs / 2;
|
||||
const delay = options.delay ??
|
||||
((milliseconds: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||
const counters: CouchdbProxyCounters = {
|
||||
requestCount: 0,
|
||||
requestBodyBytes: 0,
|
||||
responseBodyBytes: 0,
|
||||
};
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
await delay(halfDelayMs);
|
||||
|
||||
const targetUrl = new URL(request.url ?? "/", proxy);
|
||||
targetUrl.protocol = backend.protocol;
|
||||
targetUrl.host = backend.host;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(request.headers)) {
|
||||
if (value === undefined || name === "host" || name === "content-length") continue;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) headers.append(name, item);
|
||||
} else {
|
||||
headers.append(name, value);
|
||||
}
|
||||
}
|
||||
let requestBody: Buffer | undefined;
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request as AsyncIterable<Buffer | string>) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
requestBody = Buffer.concat(chunks);
|
||||
} catch {
|
||||
requestBody = undefined;
|
||||
}
|
||||
}
|
||||
counters.requestCount += 1;
|
||||
counters.requestBodyBytes += requestBody?.byteLength ?? 0;
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: requestBody === undefined ? undefined : Uint8Array.from(requestBody),
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
const responseBody = await upstream.arrayBuffer();
|
||||
counters.responseBodyBytes += responseBody.byteLength;
|
||||
await delay(halfDelayMs);
|
||||
|
||||
response.statusCode = upstream.status;
|
||||
response.statusMessage = upstream.statusText;
|
||||
responseHeaders.forEach((value, name) => response.setHeader(name, value));
|
||||
response.end(Buffer.from(responseBody));
|
||||
} catch (error) {
|
||||
console.error(`[Proxy] ${String(error)}`);
|
||||
if (!response.headersSent) {
|
||||
response.statusCode = 502;
|
||||
response.end("proxy error");
|
||||
} else {
|
||||
response.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onListening = () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(Number(proxy.port), proxy.hostname);
|
||||
});
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
directionalDelayMs: halfDelayMs,
|
||||
note:
|
||||
`local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`,
|
||||
resetCounters: () => {
|
||||
counters.requestCount = 0;
|
||||
counters.requestBodyBytes = 0;
|
||||
counters.responseBodyBytes = 0;
|
||||
},
|
||||
snapshotCounters: () => ({ ...counters }),
|
||||
stop: async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = buildConfig();
|
||||
const resultPath = readOptionalResultPath();
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-couchdb-bench");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
|
||||
if (config.managedCouchdb) {
|
||||
await startCouchdb(
|
||||
config.couchdbBackendUri,
|
||||
config.couchdbUser,
|
||||
config.couchdbPassword,
|
||||
config.couchdbDbname,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`,
|
||||
);
|
||||
await createCouchdbDatabase(
|
||||
config.couchdbBackendUri,
|
||||
config.couchdbUser,
|
||||
config.couchdbPassword,
|
||||
config.couchdbDbname,
|
||||
);
|
||||
}
|
||||
|
||||
const proxy = await startCouchdbProxy({
|
||||
backendUri: config.couchdbBackendUri,
|
||||
proxyUri: config.couchdbProxyUri,
|
||||
requestedRttMs: config.requestedRttMs,
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
applyRemoteSyncSettings(settingsA, {
|
||||
remoteType: "COUCHDB",
|
||||
couchdbUri: config.couchdbProxyUri,
|
||||
couchdbUser: config.couchdbUser,
|
||||
couchdbPassword: config.couchdbPassword,
|
||||
couchdbDbname: config.couchdbDbname,
|
||||
encrypt: config.encrypt,
|
||||
passphrase: config.passphrase,
|
||||
}),
|
||||
applyRemoteSyncSettings(settingsB, {
|
||||
remoteType: "COUCHDB",
|
||||
couchdbUri: config.couchdbProxyUri,
|
||||
couchdbUser: config.couchdbUser,
|
||||
couchdbPassword: config.couchdbPassword,
|
||||
couchdbDbname: config.couchdbDbname,
|
||||
encrypt: config.encrypt,
|
||||
passphrase: config.passphrase,
|
||||
}),
|
||||
]);
|
||||
|
||||
const seedFiles = await createDeterministicDataset({
|
||||
rootDir: vaultA,
|
||||
datasetDirName: config.datasetDirName,
|
||||
seed: config.datasetSeed,
|
||||
mdCount: config.mdFileCount,
|
||||
mdMinSizeBytes: config.mdMinSizeBytes,
|
||||
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
||||
binCount: config.binFileCount,
|
||||
binSizeBytes: config.binSizeBytes,
|
||||
});
|
||||
|
||||
const mirrorStart = nowMs();
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "mirror");
|
||||
const mirrorElapsed = nowMs() - mirrorStart;
|
||||
|
||||
const syncAStart = nowMs();
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
const syncAElapsed = nowMs() - syncAStart;
|
||||
|
||||
const syncBStart = nowMs();
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const syncBElapsed = nowMs() - syncBStart;
|
||||
|
||||
const verification = await verifyBenchmarkDataset(
|
||||
seedFiles.entries,
|
||||
config.verificationMode,
|
||||
async (entry) => {
|
||||
const pulledPath = workDir.join(
|
||||
`pulled-${entry.relativePath.split("/").join("_")}`,
|
||||
);
|
||||
await runCliOrFail(
|
||||
vaultB,
|
||||
"--settings",
|
||||
settingsB,
|
||||
"pull",
|
||||
entry.relativePath,
|
||||
pulledPath,
|
||||
);
|
||||
await assertFilesEqual(
|
||||
entry.absolutePath,
|
||||
pulledPath,
|
||||
`file mismatch after CouchDB sync: ${entry.relativePath}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const result = {
|
||||
caseName: config.caseName,
|
||||
mode: "couchdb-cli-benchmark",
|
||||
couchdbBackendUri: config.couchdbBackendUri,
|
||||
couchdbProxyUri: config.couchdbProxyUri,
|
||||
couchdbDbname: config.couchdbDbname,
|
||||
managedCouchdb: config.managedCouchdb,
|
||||
simulationTier: config.simulationTier,
|
||||
networkProfile: config.networkProfile,
|
||||
networkModel: config.networkModel,
|
||||
measurementScope: config.measurementScope,
|
||||
limitations: config.limitations,
|
||||
repeatIndex: config.repeatIndex,
|
||||
repeatCount: config.repeatCount,
|
||||
rttRequestedMs: config.requestedRttMs,
|
||||
proxyApplied: proxy.applied,
|
||||
proxyNote: proxy.note,
|
||||
proxyDirectionalDelayMs: proxy.directionalDelayMs,
|
||||
proxyConfiguredRttMs: proxy.directionalDelayMs * 2,
|
||||
proxyDelayApplication: "request-and-response",
|
||||
datasetSeed: config.datasetSeed,
|
||||
datasetDirName: config.datasetDirName,
|
||||
totalFiles: seedFiles.totalFiles,
|
||||
totalBytes: seedFiles.totalBytes,
|
||||
mdFileCount: seedFiles.mdCount,
|
||||
binFileCount: seedFiles.binCount,
|
||||
...verification,
|
||||
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
||||
syncAElapsedMs: Number(syncAElapsed.toFixed(1)),
|
||||
syncBElapsedMs: Number(syncBElapsed.toFixed(1)),
|
||||
totalSyncElapsedMs: Number(
|
||||
(syncAElapsed + syncBElapsed).toFixed(1),
|
||||
),
|
||||
throughputBytesPerSec: Number(
|
||||
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000))
|
||||
.toFixed(
|
||||
2,
|
||||
),
|
||||
),
|
||||
throughputMiBPerSec: Number(
|
||||
(seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) /
|
||||
1024 /
|
||||
1024).toFixed(4),
|
||||
),
|
||||
};
|
||||
|
||||
if (resultPath) {
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.error(
|
||||
`[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${
|
||||
formatBytes(seedFiles.totalBytes)
|
||||
}) in ${
|
||||
formatMs(
|
||||
mirrorElapsed,
|
||||
)
|
||||
}, synced in ${
|
||||
formatMs(syncAElapsed + syncBElapsed)
|
||||
} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
|
||||
);
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
if (config.managedCouchdb) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error(`[Fatal Error]`, error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runProcess } from "./helpers/process.ts";
|
||||
|
||||
type SweepResult = {
|
||||
name: string;
|
||||
runner: "p2p" | "couchdb";
|
||||
rttMs?: number;
|
||||
repeatIndex: number;
|
||||
repeatCount: number;
|
||||
result: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` +
|
||||
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
function readEnvInteger(name: string, fallback: number): number {
|
||||
const raw = readEnvString(name, String(fallback));
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${name} must be a positive integer, got '${raw}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseRttList(raw: string): number[] {
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.floor(value));
|
||||
if (values.length === 0) {
|
||||
throw new Error(`BENCH_SWEEP_RTT_MS must contain at least one positive number, got '${raw}'`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildBaseEnv(): Record<string, string> {
|
||||
return {
|
||||
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
|
||||
BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"),
|
||||
BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"),
|
||||
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
|
||||
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
|
||||
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
|
||||
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
|
||||
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
|
||||
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runBenchmark(options: {
|
||||
taskName: "bench:p2p" | "bench:couchdb";
|
||||
name: string;
|
||||
outputDir: string;
|
||||
env: Record<string, string>;
|
||||
repeatIndex: number;
|
||||
repeatCount: number;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const suffix = options.repeatCount > 1
|
||||
? `-r${String(options.repeatIndex).padStart(2, "0")}`
|
||||
: "";
|
||||
const resultPath = join(options.outputDir, `${options.name}${suffix}.json`);
|
||||
const env = {
|
||||
...options.env,
|
||||
BENCH_RESULT_JSON: resultPath,
|
||||
BENCH_REPEAT_INDEX: String(options.repeatIndex),
|
||||
BENCH_REPEAT_COUNT: String(options.repeatCount),
|
||||
};
|
||||
|
||||
console.log(`[latency-sweep] running ${options.name}`);
|
||||
const child = await runProcess(process.execPath, {
|
||||
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), options.taskName],
|
||||
cwd: import.meta.dirname,
|
||||
env,
|
||||
stdin: "null",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
if (!child.success) {
|
||||
throw new Error(`benchmark failed: ${options.name} (exit ${child.code})`);
|
||||
}
|
||||
return JSON.parse(await readFile(resultPath, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outRoot = readEnvString("BENCH_SWEEP_ROOT", join(import.meta.dirname, "bench-results"));
|
||||
const outputDir = join(outRoot, `latency-sweep-${timestamp()}`);
|
||||
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
|
||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
||||
const base = buildBaseEnv();
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const results: SweepResult[] = [];
|
||||
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
|
||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||
const p2pResult = await runBenchmark({
|
||||
taskName: "bench:p2p",
|
||||
name: "p2p-direct-local",
|
||||
outputDir,
|
||||
repeatIndex,
|
||||
repeatCount,
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-direct-local",
|
||||
BENCH_TURN_SERVERS: "",
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
name: "p2p-direct-local",
|
||||
runner: "p2p",
|
||||
repeatIndex,
|
||||
repeatCount,
|
||||
result: p2pResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rtt of rtts) {
|
||||
const name = `couchdb-rtt-${rtt}ms`;
|
||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||
const couchdbResult = await runBenchmark({
|
||||
taskName: "bench:couchdb",
|
||||
name,
|
||||
outputDir,
|
||||
repeatIndex,
|
||||
repeatCount,
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: name,
|
||||
BENCH_COUCHDB_RTT_MS: String(rtt),
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
name,
|
||||
runner: "couchdb",
|
||||
rttMs: rtt,
|
||||
repeatIndex,
|
||||
repeatCount,
|
||||
result: couchdbResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
outputDir,
|
||||
note:
|
||||
"This sweep applies half of each requested CouchDB RTT before forwarding requests and half before returning responses. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.",
|
||||
rtts,
|
||||
repeatCount,
|
||||
results,
|
||||
};
|
||||
await writeFile(join(outputDir, "summary.json"), JSON.stringify(summary, null, 2));
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
console.log(`[latency-sweep] result directory: ${outputDir}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error("[Fatal Error]", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runProcess } from "./helpers/process.ts";
|
||||
|
||||
export type BenchmarkCase = {
|
||||
name: string;
|
||||
runner: "p2p" | "couchdb";
|
||||
description: string;
|
||||
dataPath: string;
|
||||
trustBoundary: string;
|
||||
measurementScope: string;
|
||||
limitations: string[];
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvInteger(name: string, fallback: number): number {
|
||||
const value = readEnvString(name, String(fallback));
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`${name} must be a positive integer, got '${value}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${
|
||||
pad(d.getUTCDate())
|
||||
}-` +
|
||||
`${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${
|
||||
pad(d.getUTCSeconds())
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseEnv(): Record<string, string> {
|
||||
return {
|
||||
BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"),
|
||||
BENCH_MD_MIN_SIZE_BYTES: readEnvString(
|
||||
"BENCH_MD_MIN_SIZE_BYTES",
|
||||
"512",
|
||||
),
|
||||
BENCH_MD_MAX_SIZE_BYTES: readEnvString(
|
||||
"BENCH_MD_MAX_SIZE_BYTES",
|
||||
"2048",
|
||||
),
|
||||
BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"),
|
||||
BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"),
|
||||
BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"),
|
||||
BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"),
|
||||
BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||
BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"),
|
||||
LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"),
|
||||
};
|
||||
}
|
||||
|
||||
function withScopeEnv(
|
||||
env: Record<string, string>,
|
||||
options: Pick<BenchmarkCase, "measurementScope" | "limitations">,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...env,
|
||||
BENCH_MEASUREMENT_SCOPE: options.measurementScope,
|
||||
BENCH_LIMITATIONS_JSON: JSON.stringify(options.limitations),
|
||||
};
|
||||
}
|
||||
|
||||
function defineCase(testCase: BenchmarkCase): BenchmarkCase {
|
||||
return {
|
||||
...testCase,
|
||||
env: withScopeEnv(testCase.env, testCase),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCases(): BenchmarkCase[] {
|
||||
const base = buildBaseEnv();
|
||||
const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20");
|
||||
const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120");
|
||||
const localTurnServers = readEnvString(
|
||||
"BENCH_LOCAL_TURN_SERVERS",
|
||||
"turn:127.0.0.1:3478",
|
||||
);
|
||||
const shimCouchdbUri = readEnvString(
|
||||
"BENCH_SHIM_COUCHDB_URI",
|
||||
"http://couchdb-shim:5984",
|
||||
);
|
||||
const signallingShimRelay = readEnvString(
|
||||
"BENCH_SIGNAL_SHIM_RELAY",
|
||||
"ws://p2p-signalling-shim:7777/",
|
||||
);
|
||||
|
||||
return [
|
||||
defineCase({
|
||||
name: "couchdb-baseline",
|
||||
runner: "couchdb",
|
||||
description:
|
||||
"Standard self-hosted CouchDB path through a local latency proxy.",
|
||||
dataPath: "Device A -> CouchDB -> Device B",
|
||||
trustBoundary: "CouchDB operator and network path",
|
||||
measurementScope:
|
||||
"Two one-shot synchronisation phases through a CouchDB-compatible remote-store path with a local HTTP latency proxy.",
|
||||
limitations: [
|
||||
"This is not a full netem model of packet loss, jitter, MTU, bandwidth limits, or VPN encapsulation.",
|
||||
"This result should be compared with P2P only as a remote-store baseline under the same deterministic dataset.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "couchdb-baseline",
|
||||
BENCH_COUCHDB_RTT_MS: couchdbRtt,
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "p2p-direct-local",
|
||||
runner: "p2p",
|
||||
description:
|
||||
"Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.",
|
||||
dataPath: "Device A -> Device B",
|
||||
trustBoundary: "Nostr relay for signalling metadata; no TURN relay",
|
||||
measurementScope:
|
||||
"One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment, with TURN disabled; the earlier peer-list observation command is excluded.",
|
||||
limitations: [
|
||||
"The timed command includes its own signalling and connection establishment, but not the earlier peer-list observation window.",
|
||||
"This does not measure public relay operation, mobile carrier behaviour, or TURN-relayed throughput.",
|
||||
"This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-direct-local",
|
||||
BENCH_TURN_SERVERS: "",
|
||||
BENCH_SIMULATION_TIER: "1",
|
||||
BENCH_NETWORK_PROFILE: "local-direct",
|
||||
BENCH_NETWORK_MODEL: "local-runner-webrtc",
|
||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
||||
"turn-disabled-but-selected-ice-pair-not-collected",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "couchdb-tethering-vpn-proxy",
|
||||
runner: "couchdb",
|
||||
description:
|
||||
"Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.",
|
||||
dataPath:
|
||||
"Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B",
|
||||
trustBoundary: "VPN/network path and CouchDB operator",
|
||||
measurementScope:
|
||||
"Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.",
|
||||
limitations: [
|
||||
"This approximates request latency only and does not model loss, jitter, MTU, bandwidth limits, carrier NAT, or VPN encapsulation.",
|
||||
"Use the Tier 2 netem shim cases for a stronger constrained-network fixture.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "couchdb-tethering-vpn-proxy",
|
||||
BENCH_COUCHDB_RTT_MS: tetheringVpnRtt,
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "couchdb-netem-home-wifi",
|
||||
runner: "couchdb",
|
||||
description:
|
||||
"Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.",
|
||||
dataPath:
|
||||
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
|
||||
trustBoundary: "CouchDB operator and constrained network shim",
|
||||
measurementScope:
|
||||
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.",
|
||||
limitations: [
|
||||
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
|
||||
"The fixture remains a reproducible network emulation, not a field measurement on a real home network.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "couchdb-netem-home-wifi",
|
||||
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
|
||||
BENCH_COUCHDB_RTT_MS: "1",
|
||||
BENCH_SIMULATION_TIER: "2",
|
||||
BENCH_NETWORK_PROFILE: "home-wifi",
|
||||
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "couchdb-netem-tethering-vpn",
|
||||
runner: "couchdb",
|
||||
description:
|
||||
"Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.",
|
||||
dataPath:
|
||||
"Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B",
|
||||
trustBoundary:
|
||||
"CouchDB operator and constrained smartphone/VPN-like network shim",
|
||||
measurementScope:
|
||||
"Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.",
|
||||
limitations: [
|
||||
"This shapes the CouchDB TCP path, not the WebRTC P2P data path.",
|
||||
"The profile approximates smartphone/VPN constraints but is not a field measurement on a real tethered VPN connection.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "couchdb-netem-tethering-vpn",
|
||||
BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri,
|
||||
BENCH_COUCHDB_RTT_MS: "1",
|
||||
BENCH_SIMULATION_TIER: "2",
|
||||
BENCH_NETWORK_PROFILE: "tethering-vpn",
|
||||
BENCH_NETWORK_MODEL: "compose-netem-tcp-shim",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "p2p-smartphone-vpn-direct",
|
||||
runner: "p2p",
|
||||
description:
|
||||
"Direct P2P case name for smartphone tethering/VPN measurements. In this local runner it is unshaped and should be treated as a wiring check unless executed on that network.",
|
||||
dataPath:
|
||||
"Device A -> Device B when WebRTC direct connectivity succeeds",
|
||||
trustBoundary:
|
||||
"Smartphone/VPN routing policy plus Nostr signalling metadata",
|
||||
measurementScope:
|
||||
"Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.",
|
||||
limitations: [
|
||||
"In the local runner this is unshaped and must not be reported as smartphone, VPN, WAN, or Tier 2 evidence.",
|
||||
"Use only when the command is executed on the intended real network path and the selected ICE candidate pair is recorded.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-smartphone-vpn-direct",
|
||||
BENCH_TURN_SERVERS: "",
|
||||
BENCH_SIMULATION_TIER: "unmeasured",
|
||||
BENCH_NETWORK_PROFILE: "smartphone-vpn-direct-placeholder",
|
||||
BENCH_NETWORK_MODEL: "local-runner-no-netem",
|
||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
||||
"structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "p2p-signalling-netem-home-wifi",
|
||||
runner: "p2p",
|
||||
description:
|
||||
"Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.",
|
||||
dataPath:
|
||||
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
|
||||
trustBoundary:
|
||||
"Nostr signalling metadata through constrained network shim; no TURN relay",
|
||||
measurementScope:
|
||||
"One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the home-wifi netem profile; the selected WebRTC note-data path is unshaped.",
|
||||
limitations: [
|
||||
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
|
||||
"This does not shape the selected WebRTC DataChannel note-data path.",
|
||||
"This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-signalling-netem-home-wifi",
|
||||
BENCH_RELAY: signallingShimRelay,
|
||||
BENCH_TURN_SERVERS: "",
|
||||
BENCH_SIMULATION_TIER: "2",
|
||||
BENCH_NETWORK_PROFILE: "home-wifi",
|
||||
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
|
||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
||||
"selected ICE pair collected; only Nostr signalling path is shaped",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "p2p-signalling-netem-tethering-vpn",
|
||||
runner: "p2p",
|
||||
description:
|
||||
"Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.",
|
||||
dataPath:
|
||||
"Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim",
|
||||
trustBoundary:
|
||||
"Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay",
|
||||
measurementScope:
|
||||
"One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the tethering-vpn netem profile; the selected WebRTC note-data path is unshaped.",
|
||||
limitations: [
|
||||
"The timed p2p-sync command includes signalling and WebRTC connection establishment.",
|
||||
"This does not shape the selected WebRTC DataChannel note-data path.",
|
||||
"The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-signalling-netem-tethering-vpn",
|
||||
BENCH_RELAY: signallingShimRelay,
|
||||
BENCH_TURN_SERVERS: "",
|
||||
BENCH_SIMULATION_TIER: "2",
|
||||
BENCH_NETWORK_PROFILE: "tethering-vpn",
|
||||
BENCH_NETWORK_MODEL: "compose-netem-signalling-shim",
|
||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
||||
"selected ICE pair collected; only Nostr signalling path is shaped",
|
||||
},
|
||||
}),
|
||||
defineCase({
|
||||
name: "p2p-user-turn",
|
||||
runner: "p2p",
|
||||
description:
|
||||
"Optional fallback path through a local user-controlled TURN server.",
|
||||
dataPath: "Device A -> user-controlled TURN -> Device B",
|
||||
trustBoundary: "User-controlled TURN server",
|
||||
measurementScope:
|
||||
"Optional local TURN fallback wiring check with a user-controlled TURN server configured.",
|
||||
limitations: [
|
||||
"TURN configuration does not prove that the selected ICE path was relayed; interpret the recorded candidate pair.",
|
||||
"This is not evidence for public TURN relay privacy, throughput, or availability.",
|
||||
],
|
||||
env: {
|
||||
...base,
|
||||
BENCH_CASE: "p2p-user-turn",
|
||||
BENCH_TURN_SERVERS: localTurnServers,
|
||||
BENCH_SIMULATION_TIER: "1",
|
||||
BENCH_NETWORK_PROFILE: "local-turn-fallback",
|
||||
BENCH_NETWORK_MODEL: "local-runner-webrtc-turn-configured",
|
||||
BENCH_P2P_CANDIDATE_PATH_VERIFICATION:
|
||||
"turn-configured; selected ICE pair may still be direct or relayed, so interpret the recorded candidate types",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function runCase(
|
||||
testCase: BenchmarkCase,
|
||||
outputDir: string,
|
||||
repeatIndex: number,
|
||||
repeatCount: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const suffix = repeatCount > 1
|
||||
? `-r${String(repeatIndex).padStart(2, "0")}`
|
||||
: "";
|
||||
const resultPath = join(outputDir, `${testCase.name}${suffix}.json`);
|
||||
const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
|
||||
const env = {
|
||||
...testCase.env,
|
||||
BENCH_RESULT_JSON: resultPath,
|
||||
BENCH_REPEAT_INDEX: String(repeatIndex),
|
||||
BENCH_REPEAT_COUNT: String(repeatCount),
|
||||
};
|
||||
|
||||
const repeatLabel = repeatCount > 1
|
||||
? ` (${repeatIndex}/${repeatCount})`
|
||||
: "";
|
||||
console.log(
|
||||
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
|
||||
);
|
||||
const child = await runProcess(process.execPath, {
|
||||
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), taskName],
|
||||
cwd: import.meta.dirname,
|
||||
env,
|
||||
stdin: "null",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
if (!child.success) {
|
||||
throw new Error(`case failed: ${testCase.name} (exit ${child.code})`);
|
||||
}
|
||||
|
||||
const result = JSON.parse(await readFile(resultPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...testCase,
|
||||
repeatIndex,
|
||||
repeatCount,
|
||||
resultPath,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
|
||||
const requested = readEnvString(
|
||||
"BENCH_CASES",
|
||||
"couchdb-baseline,p2p-direct-local",
|
||||
);
|
||||
const names = requested
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
const byName = new Map(allCases.map((c) => [c.name, c]));
|
||||
return names.map((name) => {
|
||||
const found = byName.get(name);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Unknown BENCH_CASES entry '${name}'. Available: ${
|
||||
allCases.map((c) => c.name).join(", ")
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outRoot = readEnvString(
|
||||
"BENCH_CASES_ROOT",
|
||||
join(import.meta.dirname, "bench-results"),
|
||||
);
|
||||
const outputDir = join(outRoot, `cases-${timestamp()}`);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const allCases = buildCases();
|
||||
const cases = selectCases(allCases);
|
||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
||||
await writeFile(
|
||||
join(outputDir, "case-manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
repeatCount,
|
||||
selectedCases: cases,
|
||||
availableCases: allCases,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const results: Record<string, unknown>[] = [];
|
||||
for (const testCase of cases) {
|
||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||
results.push(
|
||||
await runCase(testCase, outputDir, repeatIndex, repeatCount),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
outputDir,
|
||||
repeatCount,
|
||||
results,
|
||||
};
|
||||
await writeFile(
|
||||
join(outputDir, "summary.json"),
|
||||
JSON.stringify(summary, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
console.log(`[bench-cases] result directory: ${outputDir}`);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error("[Fatal Error]", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
||||
import { discoverPeer } from "./helpers/p2p.ts";
|
||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { runProcess } from "./helpers/process.ts";
|
||||
|
||||
type Role = "host" | "client";
|
||||
|
||||
type NetemSummary = {
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
interface: string;
|
||||
delayMs: number;
|
||||
jitterMs: number;
|
||||
lossPercent: number;
|
||||
bandwidthMbit: number;
|
||||
mtu: number;
|
||||
tcQdisc?: string;
|
||||
ipAddr?: string;
|
||||
ipRoute?: string;
|
||||
};
|
||||
|
||||
type HostReady = {
|
||||
generatedAt: string;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
mdFileCount: number;
|
||||
binFileCount: number;
|
||||
mirrorElapsedMs: number;
|
||||
netem: NetemSummary;
|
||||
};
|
||||
|
||||
type P2PConnectionStats = {
|
||||
candidatePathCollected: boolean;
|
||||
selectedPath: string;
|
||||
localCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
||||
remoteCandidate?: { candidateType: string; protocol: string; relayProtocol: string };
|
||||
};
|
||||
|
||||
function errorToRecord(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "UnknownError",
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`${name} must be a non-negative number, got '${raw}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
async function commandOutput(command: string, args: string[]): Promise<string> {
|
||||
const output = await runProcess(command, {
|
||||
args,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
const stdout = new TextDecoder().decode(output.stdout);
|
||||
const stderr = new TextDecoder().decode(output.stderr);
|
||||
if (!output.success) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
}
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function commandOk(command: string, args: string[]): Promise<void> {
|
||||
await commandOutput(command, args);
|
||||
}
|
||||
|
||||
async function applyNetemIfRequested(): Promise<NetemSummary> {
|
||||
const enabled = readEnvString("BENCH_NETEM_ENABLED", "0") === "1";
|
||||
const profile = readEnvString("NETEM_PROFILE", "home-wifi");
|
||||
const iface = readEnvString("NETEM_INTERFACE", "eth0");
|
||||
const delayMs = readEnvNumber("NETEM_DELAY_MS", 20);
|
||||
const jitterMs = readEnvNumber("NETEM_JITTER_MS", 5);
|
||||
const lossPercent = readEnvNumber("NETEM_LOSS_PERCENT", 0.1);
|
||||
const bandwidthMbit = readEnvNumber("NETEM_BANDWIDTH_MBIT", 100);
|
||||
const mtu = readEnvNumber("NETEM_MTU", 1500);
|
||||
|
||||
const summary: NetemSummary = {
|
||||
enabled,
|
||||
profile,
|
||||
interface: iface,
|
||||
delayMs,
|
||||
jitterMs,
|
||||
lossPercent,
|
||||
bandwidthMbit,
|
||||
mtu,
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]);
|
||||
await runProcess("tc", { args: ["qdisc", "del", "dev", iface, "root"] });
|
||||
await commandOk("tc", [
|
||||
"qdisc",
|
||||
"add",
|
||||
"dev",
|
||||
iface,
|
||||
"root",
|
||||
"netem",
|
||||
"delay",
|
||||
`${delayMs}ms`,
|
||||
`${jitterMs}ms`,
|
||||
"loss",
|
||||
`${lossPercent}%`,
|
||||
"rate",
|
||||
`${bandwidthMbit}mbit`,
|
||||
]);
|
||||
summary.tcQdisc = await commandOutput("tc", ["qdisc", "show", "dev", iface]);
|
||||
summary.ipAddr = await commandOutput("ip", ["addr", "show", iface]);
|
||||
summary.ipRoute = await commandOutput("ip", ["route"]);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const fileStat = await stat(path);
|
||||
if (fileStat.isFile()) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// wait
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${path}`);
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(path: string): Promise<T> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||
}
|
||||
|
||||
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
||||
const unique = new Map<string, DatasetEntry>();
|
||||
for (const entry of [entries.find((e) => e.kind === "md"), entries.find((e) => e.kind === "bin"), entries.at(-1)]) {
|
||||
if (entry) {
|
||||
unique.set(entry.relativePath, entry);
|
||||
}
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
|
||||
try {
|
||||
const lines = (await readFile(path, "utf8"))
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
return lines.length === 0 ? undefined : (JSON.parse(lines.at(-1)!) as P2PConnectionStats);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommonConfig() {
|
||||
const runId = readEnvString("BENCH_SPLIT_RUN_ID", readEnvString("BENCH_ROOM_ID", "bench-split-run"));
|
||||
const baseWorkRoot = readEnvString("BENCH_SPLIT_WORK_ROOT", "/p2p-work");
|
||||
return {
|
||||
runId,
|
||||
workRoot: join(baseWorkRoot, runId),
|
||||
resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/test/e2e-cli/bench-results"),
|
||||
relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"),
|
||||
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
||||
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
|
||||
passphrase: readEnvString("BENCH_PASSPHRASE", "bench-split-passphrase"),
|
||||
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
|
||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 20)),
|
||||
mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 512)),
|
||||
mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 2048)),
|
||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 5)),
|
||||
binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 8192)),
|
||||
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 60),
|
||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 300),
|
||||
nodeTimeoutMs: readEnvNumber("BENCH_SPLIT_NODE_TIMEOUT_MS", 360_000),
|
||||
profile: readEnvString("BENCH_NETWORK_PROFILE", readEnvString("NETEM_PROFILE", "split-compose")),
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareP2PSettings(
|
||||
settingsPath: string,
|
||||
peerName: string,
|
||||
config: ReturnType<typeof buildCommonConfig>
|
||||
) {
|
||||
await initSettingsFile(settingsPath);
|
||||
await applyP2pSettings(
|
||||
settingsPath,
|
||||
config.roomId,
|
||||
config.passphrase,
|
||||
config.appId,
|
||||
config.relay,
|
||||
"~.*",
|
||||
config.turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(settingsPath, peerName, config.passphrase);
|
||||
}
|
||||
|
||||
async function runHost(): Promise<void> {
|
||||
const config = buildCommonConfig();
|
||||
const netem = await applyNetemIfRequested();
|
||||
await mkdir(config.workRoot, { recursive: true });
|
||||
await mkdir(config.resultRoot, { recursive: true });
|
||||
|
||||
const hostVault = join(config.workRoot, "vault-host");
|
||||
const hostSettings = join(config.workRoot, "settings-host.json");
|
||||
await mkdir(hostVault, { recursive: true });
|
||||
await prepareP2PSettings(hostSettings, "p2p-split-host", config);
|
||||
|
||||
const seedFiles = await createDeterministicDataset({
|
||||
rootDir: hostVault,
|
||||
datasetDirName: config.datasetDirName,
|
||||
seed: config.datasetSeed,
|
||||
mdCount: config.mdFileCount,
|
||||
mdMinSizeBytes: config.mdMinSizeBytes,
|
||||
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
||||
binCount: config.binFileCount,
|
||||
binSizeBytes: config.binSizeBytes,
|
||||
});
|
||||
await writeFile(
|
||||
join(config.workRoot, "sample-files.json"),
|
||||
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
|
||||
);
|
||||
|
||||
const mirrorStart = nowMs();
|
||||
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
||||
const mirrorElapsedMs = Number((nowMs() - mirrorStart).toFixed(1));
|
||||
const hostReady: HostReady = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalFiles: seedFiles.totalFiles,
|
||||
totalBytes: seedFiles.totalBytes,
|
||||
mdFileCount: seedFiles.mdCount,
|
||||
binFileCount: seedFiles.binCount,
|
||||
mirrorElapsedMs,
|
||||
netem,
|
||||
};
|
||||
await writeFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2));
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20_000);
|
||||
await writeFile(
|
||||
join(config.workRoot, "p2p-host-ready.json"),
|
||||
JSON.stringify({ generatedAt: new Date().toISOString() })
|
||||
);
|
||||
await waitForFile(join(config.workRoot, "client-done.json"), config.nodeTimeoutMs);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function runClient(): Promise<void> {
|
||||
const config = buildCommonConfig();
|
||||
const netem = await applyNetemIfRequested();
|
||||
await mkdir(config.resultRoot, { recursive: true });
|
||||
await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs);
|
||||
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
|
||||
|
||||
const clientVault = join(config.workRoot, "vault-client");
|
||||
const clientSettings = join(config.workRoot, "settings-client.json");
|
||||
const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl");
|
||||
await mkdir(clientVault, { recursive: true });
|
||||
await prepareP2PSettings(clientSettings, "p2p-split-client", config);
|
||||
|
||||
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
||||
const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||
process.env.LIVESYNC_P2P_STATS_JSONL = statsPath;
|
||||
let stage = "peer-discovery";
|
||||
let peerDiscoveryCommandElapsedMs: number | undefined;
|
||||
let syncElapsedMs: number | undefined;
|
||||
try {
|
||||
const peerDiscoveryCommandStart = nowMs();
|
||||
const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds);
|
||||
peerDiscoveryCommandElapsedMs = Number((nowMs() - peerDiscoveryCommandStart).toFixed(1));
|
||||
|
||||
stage = "p2p-sync";
|
||||
const syncStart = nowMs();
|
||||
await runCliOrFail(
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"p2p-sync",
|
||||
peer.id,
|
||||
String(config.syncTimeoutSeconds)
|
||||
);
|
||||
syncElapsedMs = Number((nowMs() - syncStart).toFixed(1));
|
||||
|
||||
stage = "sample-verification";
|
||||
const samples = await readJsonFile<DatasetEntry[]>(join(config.workRoot, "sample-files.json"));
|
||||
for (const sample of samples) {
|
||||
const pulledPath = join(config.workRoot, `pulled-${sample.relativePath.replaceAll("/", "_")}`);
|
||||
await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath);
|
||||
await assertFilesEqual(
|
||||
sample.absolutePath,
|
||||
pulledPath,
|
||||
`sample file mismatch after split sync: ${sample.relativePath}`
|
||||
);
|
||||
}
|
||||
|
||||
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
||||
const result = {
|
||||
ok: true,
|
||||
generatedAt: new Date().toISOString(),
|
||||
caseName: "p2p-split-compose",
|
||||
mode: "p2p-split-compose-benchmark",
|
||||
runId: config.runId,
|
||||
simulationTier: process.env.BENCH_NETEM_ENABLED === "1" ? "2" : "1",
|
||||
networkProfile: config.profile,
|
||||
networkModel:
|
||||
process.env.BENCH_NETEM_ENABLED === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
||||
relay: config.relay,
|
||||
turnServers: config.turnServers,
|
||||
turnEnabled: config.turnServers.trim().length > 0,
|
||||
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
||||
p2pConnectionStats,
|
||||
hostNetem: hostReady.netem,
|
||||
clientNetem: netem,
|
||||
totalFiles: hostReady.totalFiles,
|
||||
totalBytes: hostReady.totalBytes,
|
||||
mdFileCount: hostReady.mdFileCount,
|
||||
binFileCount: hostReady.binFileCount,
|
||||
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
||||
peerDiscoveryCommandElapsedMs,
|
||||
syncElapsedMs,
|
||||
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
|
||||
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
|
||||
};
|
||||
await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||
await writeFile(
|
||||
join(config.workRoot, "client-done.json"),
|
||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
|
||||
);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath);
|
||||
const result = {
|
||||
ok: false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
caseName: "p2p-split-compose",
|
||||
mode: "p2p-split-compose-benchmark",
|
||||
runId: config.runId,
|
||||
simulationTier: process.env.BENCH_NETEM_ENABLED === "1" ? "2" : "1",
|
||||
networkProfile: config.profile,
|
||||
networkModel:
|
||||
process.env.BENCH_NETEM_ENABLED === "1" ? "split-compose-egress-netem" : "split-compose-no-netem",
|
||||
relay: config.relay,
|
||||
turnServers: config.turnServers,
|
||||
turnEnabled: config.turnServers.trim().length > 0,
|
||||
p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true,
|
||||
p2pConnectionStats,
|
||||
hostNetem: hostReady.netem,
|
||||
clientNetem: netem,
|
||||
totalFiles: hostReady.totalFiles,
|
||||
totalBytes: hostReady.totalBytes,
|
||||
mdFileCount: hostReady.mdFileCount,
|
||||
binFileCount: hostReady.binFileCount,
|
||||
mirrorElapsedMs: hostReady.mirrorElapsedMs,
|
||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
||||
peerDiscoveryCommandElapsedMs,
|
||||
syncElapsedMs,
|
||||
failure: {
|
||||
stage,
|
||||
...errorToRecord(error),
|
||||
},
|
||||
};
|
||||
await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||
await writeFile(
|
||||
join(config.workRoot, "client-done.json"),
|
||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
|
||||
);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
throw error;
|
||||
} finally {
|
||||
if (previousStatsPath === undefined) {
|
||||
delete process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||
} else {
|
||||
process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const role = readEnvString("BENCH_P2P_SPLIT_ROLE", "") as Role;
|
||||
if (role === "host") {
|
||||
await runHost();
|
||||
return;
|
||||
}
|
||||
if (role === "client") {
|
||||
await runClient();
|
||||
return;
|
||||
}
|
||||
throw new Error("BENCH_P2P_SPLIT_ROLE must be 'host' or 'client'");
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error("[Fatal Error]", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import {
|
||||
applyP2pSettings,
|
||||
applyP2pTestTweaks,
|
||||
initSettingsFile,
|
||||
} from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
maybeStartCoturn,
|
||||
maybeStartLocalRelay,
|
||||
stopCoturnIfStarted,
|
||||
stopLocalRelayIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||
import {
|
||||
createDeterministicDataset,
|
||||
} from "./helpers/dataset.ts";
|
||||
import {
|
||||
type BenchmarkVerificationMode,
|
||||
parseBenchmarkVerificationMode,
|
||||
verifyBenchmarkDataset,
|
||||
} from "./helpers/benchmarkVerification.ts";
|
||||
|
||||
type BenchmarkConfig = {
|
||||
caseName: string;
|
||||
relay: string;
|
||||
appId: string;
|
||||
roomId: string;
|
||||
passphrase: string;
|
||||
turnServers: string;
|
||||
datasetDirName: string;
|
||||
datasetSeed: string;
|
||||
mdFileCount: number;
|
||||
mdMinSizeBytes: number;
|
||||
mdMaxSizeBytes: number;
|
||||
binFileCount: number;
|
||||
binSizeBytes: number;
|
||||
peersTimeoutSeconds: number;
|
||||
syncTimeoutSeconds: number;
|
||||
simulationTier: string;
|
||||
networkProfile: string;
|
||||
networkModel: string;
|
||||
candidatePathVerification: string;
|
||||
measurementScope: string;
|
||||
limitations: string[];
|
||||
verificationMode: BenchmarkVerificationMode;
|
||||
repeatIndex: number;
|
||||
repeatCount: number;
|
||||
};
|
||||
|
||||
type P2PConnectionStats = {
|
||||
generatedAt: string;
|
||||
command: string;
|
||||
peerId: string;
|
||||
peerName: string;
|
||||
candidatePathCollected: boolean;
|
||||
selectedPath: string;
|
||||
selectedPair?: {
|
||||
id: string;
|
||||
state: string;
|
||||
currentRoundTripTime: number | "unknown";
|
||||
totalRoundTripTime: number | "unknown";
|
||||
requestsSent: number | "unknown";
|
||||
responsesReceived: number | "unknown";
|
||||
packetsDiscardedOnSend: number | "unknown";
|
||||
bytesSent: number | "unknown";
|
||||
bytesReceived: number | "unknown";
|
||||
};
|
||||
localCandidate?: {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
remoteCandidate?: {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
};
|
||||
|
||||
function readEnvString(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readEnvNumber(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive number, got '${raw}'`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((item) => typeof item === "string")
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to comma-separated parsing for hand-written invocations.
|
||||
}
|
||||
|
||||
return raw
|
||||
.split("|")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
function formatMs(value: number): string {
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
const kib = value / 1024;
|
||||
if (kib < 1024) {
|
||||
return `${kib.toFixed(1)} KiB`;
|
||||
}
|
||||
const mib = kib / 1024;
|
||||
return `${mib.toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function buildConfig(): BenchmarkConfig {
|
||||
return {
|
||||
caseName: readEnvString("BENCH_CASE", "p2p-direct-local"),
|
||||
relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"),
|
||||
appId: readEnvString(
|
||||
"BENCH_APP_ID",
|
||||
"self-hosted-livesync-cli-benchmark",
|
||||
),
|
||||
roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`),
|
||||
passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`),
|
||||
turnServers: readEnvString("BENCH_TURN_SERVERS", ""),
|
||||
datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"),
|
||||
datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"),
|
||||
mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)),
|
||||
mdMinSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024),
|
||||
),
|
||||
mdMaxSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024),
|
||||
),
|
||||
binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)),
|
||||
binSizeBytes: Math.floor(
|
||||
readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024),
|
||||
),
|
||||
peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20),
|
||||
syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240),
|
||||
simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"),
|
||||
networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"),
|
||||
networkModel: readEnvString(
|
||||
"BENCH_NETWORK_MODEL",
|
||||
"local-runner-webrtc",
|
||||
),
|
||||
candidatePathVerification: readEnvString(
|
||||
"BENCH_P2P_CANDIDATE_PATH_VERIFICATION",
|
||||
"not-collected",
|
||||
),
|
||||
measurementScope: readEnvString(
|
||||
"BENCH_MEASUREMENT_SCOPE",
|
||||
"One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment; the earlier peer-list observation command is excluded.",
|
||||
),
|
||||
limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [
|
||||
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
|
||||
]),
|
||||
verificationMode: parseBenchmarkVerificationMode(
|
||||
process.env.BENCH_VERIFY_MODE,
|
||||
),
|
||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalResultPath(): string | undefined {
|
||||
const raw = process.env.BENCH_RESULT_JSON?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function readLatestP2PConnectionStats(
|
||||
statsPath: string,
|
||||
): Promise<P2PConnectionStats | undefined> {
|
||||
try {
|
||||
const text = await readFile(statsPath, "utf8");
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(lines[lines.length - 1]) as P2PConnectionStats;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = buildConfig();
|
||||
const resultPath = readOptionalResultPath();
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(config.relay);
|
||||
const coturnStarted = await maybeStartCoturn(config.turnServers);
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-bench");
|
||||
|
||||
const hostVault = workDir.join("vault-host");
|
||||
const clientVault = workDir.join("vault-client");
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientSettings = workDir.join("settings-client.json");
|
||||
const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl");
|
||||
const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||
process.env.LIVESYNC_P2P_STATS_JSONL = p2pStatsPath;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
mkdir(hostVault, { recursive: true }),
|
||||
mkdir(clientVault, { recursive: true }),
|
||||
initSettingsFile(hostSettings),
|
||||
initSettingsFile(clientSettings),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
applyP2pSettings(
|
||||
hostSettings,
|
||||
config.roomId,
|
||||
config.passphrase,
|
||||
config.appId,
|
||||
config.relay,
|
||||
"~.*",
|
||||
config.turnServers,
|
||||
),
|
||||
applyP2pSettings(
|
||||
clientSettings,
|
||||
config.roomId,
|
||||
config.passphrase,
|
||||
config.appId,
|
||||
config.relay,
|
||||
"~.*",
|
||||
config.turnServers,
|
||||
),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
applyP2pTestTweaks(
|
||||
hostSettings,
|
||||
"p2p-bench-host",
|
||||
config.passphrase,
|
||||
),
|
||||
applyP2pTestTweaks(
|
||||
clientSettings,
|
||||
"p2p-bench-client",
|
||||
config.passphrase,
|
||||
),
|
||||
]);
|
||||
|
||||
const seedFiles = await createDeterministicDataset({
|
||||
rootDir: hostVault,
|
||||
datasetDirName: config.datasetDirName,
|
||||
seed: config.datasetSeed,
|
||||
mdCount: config.mdFileCount,
|
||||
mdMinSizeBytes: config.mdMinSizeBytes,
|
||||
mdMaxSizeBytes: config.mdMaxSizeBytes,
|
||||
binCount: config.binFileCount,
|
||||
binSizeBytes: config.binSizeBytes,
|
||||
});
|
||||
|
||||
const mirrorStart = nowMs();
|
||||
await runCliOrFail(hostVault, "--settings", hostSettings, "mirror");
|
||||
const mirrorElapsed = nowMs() - mirrorStart;
|
||||
|
||||
const host = startCliInBackground(
|
||||
hostVault,
|
||||
"--settings",
|
||||
hostSettings,
|
||||
"p2p-host",
|
||||
);
|
||||
try {
|
||||
const hostReadyStart = nowMs();
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const hostReadyElapsed = nowMs() - hostReadyStart;
|
||||
|
||||
const peerDiscoveryCommandStart = nowMs();
|
||||
const peer = await discoverPeer(
|
||||
clientVault,
|
||||
clientSettings,
|
||||
config.peersTimeoutSeconds,
|
||||
);
|
||||
const peerDiscoveryCommandElapsed = nowMs() -
|
||||
peerDiscoveryCommandStart;
|
||||
|
||||
const syncStart = nowMs();
|
||||
await runCliOrFail(
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"p2p-sync",
|
||||
peer.id,
|
||||
String(config.syncTimeoutSeconds),
|
||||
);
|
||||
const syncElapsed = nowMs() - syncStart;
|
||||
|
||||
const verification = await verifyBenchmarkDataset(
|
||||
seedFiles.entries,
|
||||
config.verificationMode,
|
||||
async (entry) => {
|
||||
const pulledPath = workDir.join(
|
||||
`pulled-${entry.relativePath.replaceAll("/", "_")}`,
|
||||
);
|
||||
await runCliOrFail(
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"pull",
|
||||
entry.relativePath,
|
||||
pulledPath,
|
||||
);
|
||||
await assertFilesEqual(
|
||||
entry.absolutePath,
|
||||
pulledPath,
|
||||
`file mismatch after P2P sync: ${entry.relativePath}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const p2pConnectionStats = await readLatestP2PConnectionStats(
|
||||
p2pStatsPath,
|
||||
);
|
||||
const result = {
|
||||
caseName: config.caseName,
|
||||
mode: "p2p-cli-benchmark",
|
||||
relay: config.relay,
|
||||
turnServers: config.turnServers,
|
||||
turnEnabled: config.turnServers.trim().length > 0,
|
||||
simulationTier: config.simulationTier,
|
||||
networkProfile: config.networkProfile,
|
||||
networkModel: config.networkModel,
|
||||
measurementScope: config.measurementScope,
|
||||
limitations: config.limitations,
|
||||
repeatIndex: config.repeatIndex,
|
||||
repeatCount: config.repeatCount,
|
||||
p2pCandidatePathVerified:
|
||||
p2pConnectionStats?.candidatePathCollected === true,
|
||||
p2pCandidatePathVerification:
|
||||
p2pConnectionStats?.candidatePathCollected
|
||||
? "selected ICE candidate pair collected from RTCPeerConnection.getStats"
|
||||
: config.candidatePathVerification,
|
||||
p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected
|
||||
? "The selected ICE candidate pair was collected by the CLI benchmark. Interpret the path from the candidate types; do not infer TURN use from configuration alone."
|
||||
: config.turnServers.trim().length > 0
|
||||
? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run."
|
||||
: "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.",
|
||||
p2pConnectionStats,
|
||||
appId: config.appId,
|
||||
roomId: config.roomId,
|
||||
datasetSeed: config.datasetSeed,
|
||||
datasetDirName: config.datasetDirName,
|
||||
peerId: peer.id,
|
||||
peerName: peer.name,
|
||||
totalFiles: seedFiles.totalFiles,
|
||||
totalBytes: seedFiles.totalBytes,
|
||||
mdFileCount: seedFiles.mdCount,
|
||||
binFileCount: seedFiles.binCount,
|
||||
...verification,
|
||||
mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)),
|
||||
hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)),
|
||||
peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds,
|
||||
peerDiscoveryCommandElapsedMs: Number(
|
||||
peerDiscoveryCommandElapsed.toFixed(1),
|
||||
),
|
||||
peerDiscoveryNote:
|
||||
"p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.",
|
||||
syncElapsedMs: Number(syncElapsed.toFixed(1)),
|
||||
throughputBytesPerSec: Number(
|
||||
(seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2),
|
||||
),
|
||||
throughputMiBPerSec: Number(
|
||||
(seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024)
|
||||
.toFixed(
|
||||
4,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
if (resultPath) {
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.error(
|
||||
`[Benchmark] mirrored ${seedFiles.totalFiles} files (${
|
||||
formatBytes(
|
||||
seedFiles.totalBytes,
|
||||
)
|
||||
}) in ${formatMs(mirrorElapsed)}, ` +
|
||||
`synced in ${formatMs(syncElapsed)} ` +
|
||||
`(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`,
|
||||
);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
if (previousStatsPath === undefined) {
|
||||
delete process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||
} else {
|
||||
process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath;
|
||||
}
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error(`[Fatal Error]`, error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RESULTS_ROOT="${SCRIPT_DIR}/bench-results"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
OUT_DIR="${RESULTS_ROOT}/${TIMESTAMP}"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
echo "[bench-wrapper] output directory: ${OUT_DIR}"
|
||||
|
||||
echo "[bench-wrapper] running p2p benchmark"
|
||||
(
|
||||
cd "${SCRIPT_DIR}"
|
||||
BENCH_RESULT_JSON="${OUT_DIR}/p2p.json" node run-task.ts bench:p2p
|
||||
)
|
||||
|
||||
echo "[bench-wrapper] running couchdb benchmark with RTT ${BENCH_COUCHDB_RTT_MS:-default} ms (emulating HTTP network latency)"
|
||||
(
|
||||
cd "${SCRIPT_DIR}"
|
||||
BENCH_RESULT_JSON="${OUT_DIR}/couchdb.json" node run-task.ts bench:couchdb
|
||||
)
|
||||
|
||||
cat > "${OUT_DIR}/README.txt" <<EOF
|
||||
Bench wrapper result set
|
||||
|
||||
Generated at: ${TIMESTAMP}
|
||||
Directory: ${OUT_DIR}
|
||||
|
||||
Files:
|
||||
- p2p.json
|
||||
- couchdb.json
|
||||
EOF
|
||||
|
||||
echo "[bench-wrapper] verify outputs by cat"
|
||||
echo "========== ${OUT_DIR}/README.txt =========="
|
||||
cat "${OUT_DIR}/README.txt"
|
||||
echo "========== ${OUT_DIR}/p2p.json =========="
|
||||
cat "${OUT_DIR}/p2p.json"
|
||||
echo "========== ${OUT_DIR}/couchdb.json =========="
|
||||
cat "${OUT_DIR}/couchdb.json"
|
||||
|
||||
echo "[bench-wrapper] done"
|
||||
echo "[bench-wrapper] result directory: ${OUT_DIR}"
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
"test:helpers",
|
||||
"test:settings-writeback",
|
||||
"test:remote-administration-exit-codes",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
"test:daemon-startup",
|
||||
"test:push-pull",
|
||||
"test:decoupled-vault",
|
||||
"test:sync-two-local",
|
||||
"test:sync-locked-remote",
|
||||
"test:remote-commands",
|
||||
"test:e2e-matrix:couchdb-enc0",
|
||||
"test:e2e-matrix:couchdb-enc1",
|
||||
"test:e2e-matrix:minio-enc0",
|
||||
"test:e2e-matrix:minio-enc1"
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { REPOSITORY_ROOT } from "./helpers/paths.ts";
|
||||
import test from "node:test";
|
||||
|
||||
type FileCoverage = { f: Record<string, number> };
|
||||
const reportPath =
|
||||
process.env.LIVESYNC_CLI_COVERAGE_REPORT ?? join(REPOSITORY_ROOT, "coverage", "cli-e2e", "coverage-final.json");
|
||||
const sourceRoot = (join(REPOSITORY_ROOT, "src") + sep).replaceAll("\\", "/");
|
||||
const report = JSON.parse(await readFile(reportPath, "utf8")) as Record<string, FileCoverage>;
|
||||
function coverageFor(suffix: string): FileCoverage {
|
||||
const found = Object.entries(report).find(([path]) => path.replaceAll("\\", "/").endsWith(suffix));
|
||||
assert.ok(found, `Missing coverage for ${suffix}`);
|
||||
return found[1];
|
||||
}
|
||||
|
||||
test("CLI E2E coverage retains executed application functions after source-map remapping", () => {
|
||||
assert.ok(
|
||||
Object.keys(report).every((path) => path.replaceAll("\\", "/").startsWith(sourceRoot)),
|
||||
"The report must contain only source files from this repository"
|
||||
);
|
||||
const counts = Object.values(coverageFor("/src/apps/cli/main.ts").f);
|
||||
assert.ok(counts.length > 0, "The report must retain application function ranges, rather than default line counts");
|
||||
assert.ok(
|
||||
counts.some((count) => count > 0),
|
||||
"The CLI application must have executed"
|
||||
);
|
||||
assert.ok(
|
||||
Object.keys(report).every((path) => !path.replaceAll("\\", "/").includes("/node_modules/")),
|
||||
"The application report must exclude bundled dependencies"
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"local CLI coverage retains unexecuted P2P functions",
|
||||
{
|
||||
skip: (process.env.LIVESYNC_CLI_COVERAGE_TASK ?? "test:local") !== "test:local",
|
||||
},
|
||||
() => {
|
||||
const counts = Object.values(coverageFor("/src/apps/cli/commands/p2p.ts").f);
|
||||
assert.ok(counts.length > 0, "The report must retain P2P function ranges");
|
||||
assert.ok(
|
||||
counts.some((count) => count === 0),
|
||||
"The local suite must not report every P2P function as executed"
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { writeSync } from "node:fs";
|
||||
import { spawnProcess, type RunningProcess } from "./process.ts";
|
||||
import { CLI_DIR, TEE_ENABLED, formatTeeCommand, createLineTeeWriter } from "./cli.ts";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
|
||||
const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1";
|
||||
const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1";
|
||||
|
||||
function decorateArgs(args: string[]): string[] {
|
||||
return DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args;
|
||||
}
|
||||
|
||||
async function pump(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
sink: (text: string) => void,
|
||||
teeTarget: { write: (chunk: Uint8Array) => void; close: () => void } | null
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
const dec = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
sink(dec.decode(value, { stream: true }));
|
||||
if (teeTarget) {
|
||||
teeTarget.write(value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (teeTarget) teeTarget.close();
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export class BackgroundCliProcess {
|
||||
#stdout = "";
|
||||
#stderr = "";
|
||||
#stdoutDone: Promise<void>;
|
||||
#stderrDone: Promise<void>;
|
||||
|
||||
readonly child: RunningProcess;
|
||||
readonly args: string[];
|
||||
|
||||
constructor(child: RunningProcess, args: string[]) {
|
||||
this.child = child;
|
||||
this.args = args;
|
||||
const cliArgs = decorateArgs(args);
|
||||
this.#stdoutDone = pump(
|
||||
child.stdout,
|
||||
(text) => {
|
||||
this.#stdout += text;
|
||||
},
|
||||
TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => writeSync(1, chunk)) : null
|
||||
);
|
||||
this.#stderrDone = pump(
|
||||
child.stderr,
|
||||
(text) => {
|
||||
this.#stderr += text;
|
||||
},
|
||||
TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => writeSync(2, chunk)) : null
|
||||
);
|
||||
}
|
||||
|
||||
get stdout(): string {
|
||||
return this.#stdout;
|
||||
}
|
||||
|
||||
get stderr(): string {
|
||||
return this.#stderr;
|
||||
}
|
||||
|
||||
get combined(): string {
|
||||
return this.#stdout + this.#stderr;
|
||||
}
|
||||
|
||||
async waitUntilContains(needle: string, timeoutMs = 15000): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (this.combined.includes(needle)) return;
|
||||
const status = await Promise.race([
|
||||
this.child.status.then((s) => ({ type: "status" as const, status: s })),
|
||||
new Promise<{ type: "tick" }>((resolve) => setTimeout(() => resolve({ type: "tick" }), 100)),
|
||||
]);
|
||||
if (status.type === "status") {
|
||||
throw new Error(
|
||||
`Background CLI exited before '${needle}' appeared (code ${status.status.code})\n${this.combined}`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for '${needle}'\n${this.combined}`);
|
||||
}
|
||||
|
||||
async stop(): Promise<number> {
|
||||
try {
|
||||
this.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore already-exited processes
|
||||
}
|
||||
const status = await this.child.status;
|
||||
await Promise.all([this.#stdoutDone, this.#stderrDone]);
|
||||
return status.code;
|
||||
}
|
||||
}
|
||||
|
||||
export function startCliInBackground(...args: string[]): BackgroundCliProcess {
|
||||
const cliArgs = decorateArgs(args);
|
||||
const child = spawnProcess(process.execPath, {
|
||||
args: [CLI_DIST, ...cliArgs],
|
||||
cwd: CLI_DIR,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
if (TEE_ENABLED) {
|
||||
writeSync(
|
||||
1,
|
||||
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`)
|
||||
);
|
||||
}
|
||||
|
||||
return new BackgroundCliProcess(child, args);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { DatasetEntry } from "./dataset.ts";
|
||||
|
||||
export type BenchmarkVerificationMode = "all" | "sample";
|
||||
|
||||
export type BenchmarkVerificationResult = {
|
||||
verificationMode: BenchmarkVerificationMode;
|
||||
verifiedFiles: number;
|
||||
verificationComplete: boolean;
|
||||
datasetDigestSha256: string;
|
||||
};
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
export function parseBenchmarkVerificationMode(
|
||||
raw: string | undefined,
|
||||
fallback: BenchmarkVerificationMode = "sample",
|
||||
): BenchmarkVerificationMode {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return fallback;
|
||||
if (value === "all" || value === "sample") return value;
|
||||
throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`);
|
||||
}
|
||||
|
||||
export function selectVerificationEntries(
|
||||
entries: DatasetEntry[],
|
||||
mode: BenchmarkVerificationMode,
|
||||
): DatasetEntry[] {
|
||||
if (mode === "all" || entries.length === 0) return [...entries];
|
||||
|
||||
const md = entries.find((entry) => entry.kind === "md");
|
||||
const bin = entries.find((entry) => entry.kind === "bin");
|
||||
const middle = entries[Math.floor(entries.length / 2)];
|
||||
const last = entries[entries.length - 1];
|
||||
const selected = new Map<string, DatasetEntry>();
|
||||
for (const entry of [md, bin, middle, last]) {
|
||||
if (entry) selected.set(entry.relativePath, entry);
|
||||
}
|
||||
return [...selected.values()];
|
||||
}
|
||||
|
||||
export async function computeDatasetDigestSha256(
|
||||
entries: DatasetEntry[],
|
||||
): Promise<string> {
|
||||
const manifest: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const contentDigest = sha256(await readFile(entry.absolutePath));
|
||||
manifest.push(
|
||||
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
|
||||
);
|
||||
}
|
||||
return sha256(new TextEncoder().encode(manifest.join("\n")));
|
||||
}
|
||||
|
||||
export async function verifyBenchmarkDataset(
|
||||
entries: DatasetEntry[],
|
||||
mode: BenchmarkVerificationMode,
|
||||
verifyEntry: (entry: DatasetEntry) => Promise<void>,
|
||||
): Promise<BenchmarkVerificationResult> {
|
||||
const selected = selectVerificationEntries(entries, mode);
|
||||
for (const entry of selected) {
|
||||
await verifyEntry(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
verificationMode: mode,
|
||||
verifiedFiles: selected.length,
|
||||
verificationComplete: selected.length === entries.length,
|
||||
datasetDigestSha256: await computeDatasetDigestSha256(entries),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { writeSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { spawnProcess } from "./process.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { CLI_DIR, CLI_DIST } from "./paths.ts";
|
||||
export { CLI_DIR, CLI_DIST } from "./paths.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface CliResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
/** stdout + stderr concatenated — useful for assertion messages. */
|
||||
combined: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export const TEE_ENABLED = process.env.LIVESYNC_TEST_TEE === "1";
|
||||
const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1";
|
||||
const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatTeeCommand(args: string[]): string {
|
||||
return [process.execPath, CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
|
||||
}
|
||||
|
||||
export function createLineTeeWriter(
|
||||
pid: number,
|
||||
streamName: "stdout" | "stderr",
|
||||
writer: (chunk: Uint8Array) => void
|
||||
): { write: (chunk: Uint8Array) => void; close: () => void } {
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
let pending = "";
|
||||
let headerWritten = false;
|
||||
const emitLine = (line: string) => {
|
||||
if (!headerWritten) {
|
||||
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}]\n`));
|
||||
headerWritten = true;
|
||||
}
|
||||
writer(enc.encode(`[CLI tee pid=${pid}:${streamName}] ${line}\n`));
|
||||
};
|
||||
|
||||
const flush = (final = false) => {
|
||||
let index = pending.indexOf("\n");
|
||||
while (index >= 0) {
|
||||
const line = pending.slice(0, index).replace(/\r$/, "");
|
||||
pending = pending.slice(index + 1);
|
||||
emitLine(line);
|
||||
index = pending.indexOf("\n");
|
||||
}
|
||||
if (final && pending.length > 0) {
|
||||
emitLine(pending.replace(/\r$/, ""));
|
||||
pending = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
write(chunk: Uint8Array) {
|
||||
pending += dec.decode(chunk, { stream: true });
|
||||
flush(false);
|
||||
},
|
||||
close() {
|
||||
pending += dec.decode();
|
||||
flush(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
teeTarget: { write: (chunk: Uint8Array) => void; close: () => void } | null
|
||||
): Promise<Uint8Array> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
if (teeTarget) {
|
||||
teeTarget.write(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (teeTarget) {
|
||||
teeTarget.close();
|
||||
}
|
||||
reader.releaseLock();
|
||||
}
|
||||
return concatChunks(chunks);
|
||||
}
|
||||
|
||||
async function runNodeCommand(args: string[], stdinData?: Uint8Array): Promise<CliResult> {
|
||||
const cliArgs = DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args;
|
||||
const child = spawnProcess(process.execPath, {
|
||||
args: [CLI_DIST, ...cliArgs],
|
||||
cwd: CLI_DIR,
|
||||
stdin: stdinData ? "piped" : "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
|
||||
if (TEE_ENABLED) {
|
||||
writeSync(1, new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`));
|
||||
}
|
||||
|
||||
const stdoutPromise = collectStream(
|
||||
child.stdout,
|
||||
TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => writeSync(1, chunk)) : null
|
||||
);
|
||||
const stderrPromise = collectStream(
|
||||
child.stderr,
|
||||
TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => writeSync(2, chunk)) : null
|
||||
);
|
||||
|
||||
if (stdinData) {
|
||||
const w = child.stdin.getWriter();
|
||||
await w.write(stdinData);
|
||||
await w.close();
|
||||
}
|
||||
|
||||
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
|
||||
|
||||
const dec = new TextDecoder();
|
||||
const out = dec.decode(stdout);
|
||||
const err = dec.decode(stderr);
|
||||
return { stdout: out, stderr: err, combined: out + err, code: status.code };
|
||||
}
|
||||
|
||||
function isTransientNetworkError(message: string): boolean {
|
||||
const m = message.toLowerCase();
|
||||
return (
|
||||
m.includes("fetch failed") ||
|
||||
m.includes("econnreset") ||
|
||||
m.includes("econnrefused") ||
|
||||
m.includes("und_err_socket") ||
|
||||
m.includes("other side closed")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core runners
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the CLI (node dist/index.cjs) with the supplied arguments.
|
||||
* Pass the vault / DB path as the first argument, exactly as the bash helpers
|
||||
* do. Does NOT throw on non-zero exit — check `.code` yourself.
|
||||
*/
|
||||
export async function runCli(...args: string[]): Promise<CliResult> {
|
||||
const retries = Number(process.env.LIVESYNC_CLI_RETRY ?? "0");
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const result = await runNodeCommand(args);
|
||||
if (result.code === 0) return result;
|
||||
|
||||
if (attempt >= retries || !isTransientNetworkError(result.combined)) {
|
||||
return result;
|
||||
}
|
||||
const waitMs = 400 * (attempt + 1);
|
||||
console.warn(`[WARN] transient CLI failure, retrying (${attempt + 1}/${retries}) in ${waitMs}ms`);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the CLI and throw if it exits non-zero. Returns stdout.
|
||||
*/
|
||||
export async function runCliOrFail(...args: string[]): Promise<string> {
|
||||
const r = await runCli(...args);
|
||||
if (r.code !== 0) {
|
||||
throw new Error(`CLI exited with code ${r.code}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`);
|
||||
}
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the CLI with data piped to stdin (equivalent to `echo … | run_cli …`
|
||||
* or `cat file | run_cli …`).
|
||||
*/
|
||||
export async function runCliWithInput(input: string | Uint8Array, ...args: string[]): Promise<CliResult> {
|
||||
const data = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
||||
|
||||
const retries = Number(process.env.LIVESYNC_CLI_RETRY ?? "0");
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const result = await runNodeCommand(args, data);
|
||||
if (result.code === 0) return result;
|
||||
|
||||
if (attempt >= retries || !isTransientNetworkError(result.combined)) {
|
||||
return result;
|
||||
}
|
||||
const waitMs = 400 * (attempt + 1);
|
||||
console.warn(`[WARN] transient CLI(stdin) failure, retrying (${attempt + 1}/${retries}) in ${waitMs}ms`);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* runCliWithInput — throws on non-zero exit, returns stdout.
|
||||
*/
|
||||
export async function runCliWithInputOrFail(input: string | Uint8Array, ...args: string[]): Promise<string> {
|
||||
const r = await runCliWithInput(input, ...args);
|
||||
if (r.code !== 0) {
|
||||
throw new Error(`CLI (with stdin) exited with code ${r.code}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`);
|
||||
}
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Strip the CLIWatchAdapter banner line that `cat` emits. */
|
||||
export function sanitiseCatStdout(raw: string): string {
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter((l) => l !== "[CLIWatchAdapter] File watching is not enabled in CLI version")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertions (parity with test-helpers.sh)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function assertContains(haystack: string, needle: string, message: string): void {
|
||||
if (!haystack.includes(needle)) {
|
||||
throw new Error(`[FAIL] ${message}\nExpected to find: ${JSON.stringify(needle)}\nActual output:\n${haystack}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotContains(haystack: string, needle: string, message: string): void {
|
||||
if (haystack.includes(needle)) {
|
||||
throw new Error(`[FAIL] ${message}\nDid NOT expect: ${JSON.stringify(needle)}\nActual output:\n${haystack}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertFilesEqual(expectedPath: string, actualPath: string, message: string): Promise<void> {
|
||||
const [expected, actual] = await Promise.all([readFile(expectedPath), readFile(actualPath)]);
|
||||
if (expected.length !== actual.length || expected.some((b, i) => b !== actual[i])) {
|
||||
const hex = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
|
||||
throw new Error(
|
||||
`[FAIL] ${message}\nexpected SHA-256: ${await hex(expected)}\nactual SHA-256: ${await hex(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function readJsonFile<T = Record<string, unknown>>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
export function jsonStringField(jsonText: string, field: string): string {
|
||||
const data = JSON.parse(jsonText) as Record<string, unknown>;
|
||||
const value = data[field];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function jsonFieldIsNa(data: Record<string, unknown>, field: string): boolean {
|
||||
return data[field] === "N/A";
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { copyFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { runProcess } from "./process.ts";
|
||||
import type { DatasetEntry, DatasetKind } from "./dataset.ts";
|
||||
import { REPOSITORY_ROOT } from "./paths.ts";
|
||||
|
||||
export type CompressionDatasetEntry = DatasetEntry & {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CompressionDataset = {
|
||||
entries: CompressionDatasetEntry[];
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
bytesByKind: Record<DatasetKind, number>;
|
||||
filesByKind: Record<DatasetKind, number>;
|
||||
jpegGenerator: string;
|
||||
};
|
||||
|
||||
export type JpegEncoder = (inputPpm: string, outputJpeg: string) => Promise<string>;
|
||||
|
||||
const ALL_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
||||
|
||||
function fnv1a32(input: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i) & 0xff;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function createXorshift32(seed: number): () => number {
|
||||
let state = seed || 0x9e3779b9;
|
||||
return () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return state >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function createSyntheticPpm(width: number, height: number, seed: string, textured: boolean): Uint8Array {
|
||||
const header = new TextEncoder().encode(`P6\n${width} ${height}\n255\n`);
|
||||
const pixels = new Uint8Array(width * height * 3);
|
||||
const nextRandom = createXorshift32(fnv1a32(seed));
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 3;
|
||||
const noise = textured ? (nextRandom() & 0x3f) - 32 : 0;
|
||||
pixels[offset] = Math.max(0, Math.min(255, Math.floor((x * 255) / (width - 1)) + noise));
|
||||
pixels[offset + 1] = Math.max(0, Math.min(255, Math.floor((y * 255) / (height - 1)) + noise));
|
||||
pixels[offset + 2] = Math.max(0, Math.min(255, Math.floor(((x + y) * 255) / (width + height - 2)) - noise));
|
||||
}
|
||||
}
|
||||
const result = new Uint8Array(header.length + pixels.length);
|
||||
result.set(header);
|
||||
result.set(pixels, header.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function gzip(input: Uint8Array): Promise<Uint8Array> {
|
||||
const copied = new Uint8Array(input.byteLength);
|
||||
copied.set(input);
|
||||
const stream = new Blob([copied.buffer]).stream().pipeThrough(new CompressionStream("gzip"));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function encodeJpegWithCjpeg(inputPpm: string, outputJpeg: string): Promise<string> {
|
||||
let result: Awaited<ReturnType<typeof runProcess>>;
|
||||
try {
|
||||
result = await runProcess("cjpeg", {
|
||||
args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm],
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
||||
throw new Error(
|
||||
"cjpeg is required for the compression benchmark. Use the Compose runner or install libjpeg tools."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(`cjpeg failed: ${new TextDecoder().decode(result.stderr)}`);
|
||||
}
|
||||
return "cjpeg quality=85, optimise=true, synthetic PPM 640x480";
|
||||
}
|
||||
|
||||
async function writeRandomBinary(path: string, size: number, seed: string): Promise<void> {
|
||||
const bytes = new Uint8Array(size);
|
||||
const nextRandom = createXorshift32(fnv1a32(seed));
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = nextRandom() & 0xff;
|
||||
}
|
||||
await writeFile(path, bytes);
|
||||
}
|
||||
|
||||
export async function createCompressionBenchmarkDataset(options: {
|
||||
rootDir: string;
|
||||
datasetDirName?: string;
|
||||
repositoryRoot?: string;
|
||||
seed?: string;
|
||||
jpegEncoder?: JpegEncoder;
|
||||
}): Promise<CompressionDataset> {
|
||||
const datasetDirName = options.datasetDirName ?? "compression-benchmark";
|
||||
const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT;
|
||||
const seed = options.seed ?? "livesync-compression-benchmark";
|
||||
const jpegEncoder = options.jpegEncoder ?? encodeJpegWithCjpeg;
|
||||
const datasetRoot = join(options.rootDir, datasetDirName);
|
||||
const entries: CompressionDatasetEntry[] = [];
|
||||
let jpegGenerator = "";
|
||||
|
||||
for (const kind of ALL_KINDS) {
|
||||
await mkdir(join(datasetRoot, kind), { recursive: true });
|
||||
}
|
||||
|
||||
const addFile = async (kind: DatasetKind, absolutePath: string, source: string) => {
|
||||
const relativePath = absolutePath
|
||||
.slice(options.rootDir.length + 1)
|
||||
.split("\\")
|
||||
.join("/");
|
||||
const size = (await stat(absolutePath)).size;
|
||||
entries.push({ kind, relativePath, absolutePath, size, source });
|
||||
};
|
||||
|
||||
const copyRepositoryFile = async (kind: DatasetKind, sourcePath: string, targetName: string) => {
|
||||
const destination = join(datasetRoot, kind, targetName);
|
||||
await copyFile(join(repositoryRoot, sourcePath), destination);
|
||||
await addFile(kind, destination, sourcePath);
|
||||
};
|
||||
|
||||
await copyRepositoryFile("md", "docs/settings.md", "settings.md");
|
||||
await copyRepositoryFile("md", "docs/quick_setup.md", "quick-setup.md");
|
||||
await copyRepositoryFile("md", "updates.md", "updates.md");
|
||||
await copyRepositoryFile("png", "instruction_images/cloudant_1.png", "cloudant-1.png");
|
||||
await copyRepositoryFile(
|
||||
"png",
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"quick-setup-first-setup-uri.png"
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||
await copyRepositoryFile(
|
||||
"ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
"ReplicateResultProcessor.ts"
|
||||
);
|
||||
|
||||
const markdownBytes = await readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
await writeFile(gzipPath, await gzip(markdownBytes));
|
||||
await addFile("gz", gzipPath, "generated gzip of docs/settings.md");
|
||||
|
||||
const randomPath = join(datasetRoot, "bin", "deterministic-random.bin");
|
||||
await writeRandomBinary(randomPath, 256 * 1024, seed);
|
||||
await addFile("bin", randomPath, `deterministic xorshift32 seed=${seed}`);
|
||||
|
||||
for (const [name, textured] of [
|
||||
["smooth-gradient.jpg", false],
|
||||
["textured-gradient.jpg", true],
|
||||
] as const) {
|
||||
const temporaryDirectory = await mkdtemp(join(options.rootDir, "compression-jpeg-"));
|
||||
const ppmPath = join(temporaryDirectory, "input.ppm");
|
||||
const jpegPath = join(datasetRoot, "jpg", name);
|
||||
try {
|
||||
await writeFile(ppmPath, createSyntheticPpm(640, 480, `${seed}-${name}`, textured));
|
||||
jpegGenerator = await jpegEncoder(ppmPath, jpegPath);
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
await addFile("jpg", jpegPath, `${jpegGenerator}; ${textured ? "textured" : "smooth"}`);
|
||||
}
|
||||
|
||||
const bytesByKind = Object.fromEntries(ALL_KINDS.map((kind) => [kind, 0])) as Record<DatasetKind, number>;
|
||||
const filesByKind = Object.fromEntries(ALL_KINDS.map((kind) => [kind, 0])) as Record<DatasetKind, number>;
|
||||
for (const entry of entries) {
|
||||
bytesByKind[entry.kind] += entry.size;
|
||||
filesByKind[entry.kind] += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
totalFiles: entries.length,
|
||||
totalBytes: entries.reduce((sum, entry) => sum + entry.size, 0),
|
||||
bytesByKind,
|
||||
filesByKind,
|
||||
jpegGenerator,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type DeterministicDatasetConfig = {
|
||||
rootDir: string;
|
||||
datasetDirName: string;
|
||||
seed: string;
|
||||
mdCount: number;
|
||||
mdMinSizeBytes: number;
|
||||
mdMaxSizeBytes: number;
|
||||
binCount: number;
|
||||
binSizeBytes: number;
|
||||
};
|
||||
|
||||
export type DatasetKind = "md" | "jpg" | "png" | "json" | "ts" | "gz" | "bin";
|
||||
|
||||
export type DatasetEntry = {
|
||||
kind: DatasetKind;
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type DeterministicDataset = {
|
||||
rootDir: string;
|
||||
datasetDirName: string;
|
||||
seed: string;
|
||||
entries: DatasetEntry[];
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
mdCount: number;
|
||||
binCount: number;
|
||||
};
|
||||
|
||||
function fnv1a32(input: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i) & 0xff;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function createXorshift32(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
if (state === 0) {
|
||||
state = 0x9e3779b9;
|
||||
}
|
||||
return () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return state >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function createTextBytes(size: number, fileIndex: number, seed: string): Uint8Array {
|
||||
const template =
|
||||
`# Bench file ${fileIndex}\n` +
|
||||
`seed: ${seed}\n` +
|
||||
"lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n";
|
||||
|
||||
const templateBytes = new TextEncoder().encode(template);
|
||||
const out = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
out[i] = templateBytes[i % templateBytes.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function createDeterministicDataset(config: DeterministicDatasetConfig): Promise<DeterministicDataset> {
|
||||
if (config.mdCount < 0 || config.binCount < 0) {
|
||||
throw new Error("mdCount and binCount must be non-negative");
|
||||
}
|
||||
if (config.mdMinSizeBytes <= 0 || config.mdMaxSizeBytes <= 0 || config.binSizeBytes <= 0) {
|
||||
throw new Error("all size values must be positive");
|
||||
}
|
||||
if (config.mdMinSizeBytes > config.mdMaxSizeBytes) {
|
||||
throw new Error("mdMinSizeBytes must be <= mdMaxSizeBytes");
|
||||
}
|
||||
|
||||
const datasetRoot = join(config.rootDir, config.datasetDirName);
|
||||
const mdDir = join(datasetRoot, "md");
|
||||
const binDir = join(datasetRoot, "bin");
|
||||
await mkdir(mdDir, { recursive: true });
|
||||
await mkdir(binDir, { recursive: true });
|
||||
|
||||
const nextRandom = createXorshift32(fnv1a32(config.seed));
|
||||
const mdRange = config.mdMaxSizeBytes - config.mdMinSizeBytes + 1;
|
||||
const entries: DatasetEntry[] = [];
|
||||
|
||||
for (let index = 0; index < config.mdCount; index++) {
|
||||
const size = config.mdMinSizeBytes + (nextRandom() % mdRange);
|
||||
const relativePath = `${config.datasetDirName}/md/file-${String(index).padStart(4, "0")}.md`;
|
||||
const absolutePath = join(config.rootDir, ...relativePath.split("/"));
|
||||
const body = createTextBytes(size, index, config.seed);
|
||||
await writeFile(absolutePath, body);
|
||||
entries.push({ kind: "md", relativePath, absolutePath, size });
|
||||
}
|
||||
|
||||
for (let index = 0; index < config.binCount; index++) {
|
||||
const size = config.binSizeBytes;
|
||||
const relativePath = `${config.datasetDirName}/bin/file-${String(index).padStart(4, "0")}.bin`;
|
||||
const absolutePath = join(config.rootDir, ...relativePath.split("/"));
|
||||
const body = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
body[i] = nextRandom() & 0xff;
|
||||
}
|
||||
await writeFile(absolutePath, body);
|
||||
entries.push({ kind: "bin", relativePath, absolutePath, size });
|
||||
}
|
||||
|
||||
const totalBytes = entries.reduce((sum, e) => sum + e.size, 0);
|
||||
return {
|
||||
rootDir: config.rootDir,
|
||||
datasetDirName: config.datasetDirName,
|
||||
seed: config.seed,
|
||||
entries,
|
||||
totalFiles: entries.length,
|
||||
totalBytes,
|
||||
mdCount: config.mdCount,
|
||||
binCount: config.binCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import { writeSync } from "node:fs";
|
||||
import { spawnProcess } from "./process.ts";
|
||||
/**
|
||||
* Docker service management for tests.
|
||||
*
|
||||
* CouchDB start/stop/init is implemented directly using `docker` CLI commands
|
||||
* and the Fetch API, so it works on any platform where Docker (Desktop) is
|
||||
* available — including Windows — without needing bash.
|
||||
*/
|
||||
|
||||
type DockerInvoker = {
|
||||
bin: string;
|
||||
prefix: string[];
|
||||
label: string;
|
||||
};
|
||||
|
||||
let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
|
||||
const DOCKER_TEE = process.env.LIVESYNC_DOCKER_TEE === "1" || process.env.LIVESYNC_TEST_TEE === "1";
|
||||
const trackedContainers = new Set<string>();
|
||||
const CLEANUP_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
||||
let signalCleanupHandlersInstalled = false;
|
||||
let signalCleanupInProgress = false;
|
||||
const signalCleanupHandlers = new Map<NodeJS.Signals, () => void>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level docker wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCommand(command: string): { bin: string; prefix: string[] } {
|
||||
const parts = command.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
throw new Error("LIVESYNC_DOCKER_COMMAND is empty");
|
||||
}
|
||||
return { bin: parts[0], prefix: parts.slice(1) };
|
||||
}
|
||||
|
||||
async function collectStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
teeTarget: ((chunk: Uint8Array) => void) | null
|
||||
): Promise<Uint8Array> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
chunks.push(value);
|
||||
if (teeTarget) {
|
||||
teeTarget(value);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
try {
|
||||
const child = spawnProcess(bin, {
|
||||
args,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => writeSync(1, chunk) : null);
|
||||
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => writeSync(2, chunk) : null);
|
||||
const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
|
||||
const dec = new TextDecoder();
|
||||
const result = {
|
||||
code: status.code,
|
||||
stdout: dec.decode(stdout),
|
||||
stderr: dec.decode(stderr),
|
||||
};
|
||||
return result;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
code: 127,
|
||||
stdout: "",
|
||||
stderr: `Command not found: ${bin}`,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDockerInvoker(): Promise<DockerInvoker> {
|
||||
const custom = process.env.LIVESYNC_DOCKER_COMMAND?.trim();
|
||||
if (custom) {
|
||||
const parsed = parseCommand(custom);
|
||||
const runner: DockerInvoker = {
|
||||
...parsed,
|
||||
label: `custom(${custom})`,
|
||||
};
|
||||
|
||||
// Validate custom command eagerly so misconfiguration fails fast.
|
||||
const checkArgs = runner.prefix.length === 0 ? ["--version"] : [...runner.prefix, "docker", "--version"];
|
||||
const check = await runCommand(runner.bin, checkArgs);
|
||||
if (check.code !== 0) {
|
||||
throw new Error(`LIVESYNC_DOCKER_COMMAND is not usable: ${custom}\n${check.stderr || check.stdout}`);
|
||||
}
|
||||
return runner;
|
||||
}
|
||||
|
||||
const mode = (process.env.LIVESYNC_DOCKER_MODE ?? "auto").toLowerCase();
|
||||
const onWindows = process.platform === "win32";
|
||||
|
||||
const native: DockerInvoker = { bin: "docker", prefix: [], label: "docker" };
|
||||
const wsl: DockerInvoker = { bin: "wsl", prefix: [], label: "wsl docker" };
|
||||
|
||||
if (mode === "native") {
|
||||
return native;
|
||||
}
|
||||
if (mode === "wsl") {
|
||||
return wsl;
|
||||
}
|
||||
if (mode !== "auto") {
|
||||
throw new Error(`Unsupported LIVESYNC_DOCKER_MODE='${mode}'. Use auto, native, or wsl.`);
|
||||
}
|
||||
|
||||
// On Windows we prefer `wsl docker` first, then native docker.
|
||||
// This typically works better in setups where Docker is installed only in
|
||||
// WSL and not exposed as docker.exe on PATH.
|
||||
const candidates = onWindows ? [wsl, native] : [native, wsl];
|
||||
for (const c of candidates) {
|
||||
if (c.bin === "docker") {
|
||||
const r = await runCommand("docker", ["--version"]);
|
||||
if (r.code === 0) return c;
|
||||
continue;
|
||||
}
|
||||
const r = await runCommand("wsl", ["docker", "--version"]);
|
||||
if (r.code === 0) return c;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"Docker command is not available.",
|
||||
"Set one of:",
|
||||
"- LIVESYNC_DOCKER_MODE=native",
|
||||
"- LIVESYNC_DOCKER_MODE=wsl",
|
||||
"- LIVESYNC_DOCKER_COMMAND='docker'",
|
||||
"- LIVESYNC_DOCKER_COMMAND='wsl docker'",
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
async function getDockerInvoker(): Promise<DockerInvoker> {
|
||||
if (!dockerInvokerPromise) {
|
||||
dockerInvokerPromise = resolveDockerInvoker().then((r) => {
|
||||
console.log(`[INFO] docker runner: ${r.label}`);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return await dockerInvokerPromise;
|
||||
}
|
||||
|
||||
async function docker(...args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const invoker = await getDockerInvoker();
|
||||
|
||||
// Either:
|
||||
// docker <args>
|
||||
// Or:
|
||||
// wsl docker <args>
|
||||
const finalArgs =
|
||||
invoker.prefix.length === 0
|
||||
? invoker.bin === "wsl"
|
||||
? ["docker", ...args]
|
||||
: args
|
||||
: [...invoker.prefix, ...args];
|
||||
|
||||
const r = await runCommand(invoker.bin, finalArgs);
|
||||
return { code: r.code, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
|
||||
async function dockerOrFail(...args: string[]): Promise<string> {
|
||||
const r = await docker(...args);
|
||||
if (r.code !== 0) {
|
||||
throw new Error(`docker ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
|
||||
}
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
async function stopAndRemoveContainer(container: string): Promise<void> {
|
||||
await docker("stop", container).catch(() => {});
|
||||
await docker("rm", "-v", container).catch(() => {});
|
||||
}
|
||||
|
||||
async function cleanupTrackedContainers(reason: string): Promise<void> {
|
||||
const names = [...trackedContainers];
|
||||
if (names.length === 0) return;
|
||||
|
||||
console.warn(`[WARN] cleaning up tracked containers on ${reason}: ${names.join(", ")}`);
|
||||
for (const container of names.reverse()) {
|
||||
await stopAndRemoveContainer(container);
|
||||
trackedContainers.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignalCleanup(signal: NodeJS.Signals): Promise<void> {
|
||||
if (signalCleanupInProgress) return;
|
||||
signalCleanupInProgress = true;
|
||||
try {
|
||||
await cleanupTrackedContainers(`signal ${signal}`);
|
||||
} finally {
|
||||
process.exit(signal === "SIGINT" ? 130 : 143);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSignalCleanupHandlers(): void {
|
||||
if (signalCleanupHandlersInstalled) return;
|
||||
signalCleanupHandlersInstalled = true;
|
||||
for (const signal of CLEANUP_SIGNALS) {
|
||||
const listener = () => {
|
||||
void handleSignalCleanup(signal);
|
||||
};
|
||||
try {
|
||||
process.on(signal, listener);
|
||||
signalCleanupHandlers.set(signal, listener);
|
||||
} catch {
|
||||
// Unsupported signal on this platform.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeSignalCleanupHandlers(): void {
|
||||
if (!signalCleanupHandlersInstalled) return;
|
||||
for (const [signal, listener] of signalCleanupHandlers) {
|
||||
try {
|
||||
process.off(signal, listener);
|
||||
} catch {
|
||||
// Ignore if already removed or unsupported.
|
||||
}
|
||||
}
|
||||
signalCleanupHandlers.clear();
|
||||
signalCleanupHandlersInstalled = false;
|
||||
}
|
||||
|
||||
function trackContainer(container: string): void {
|
||||
ensureSignalCleanupHandlers();
|
||||
trackedContainers.add(container);
|
||||
}
|
||||
|
||||
function untrackContainer(container: string): void {
|
||||
trackedContainers.delete(container);
|
||||
if (trackedContainers.size === 0) {
|
||||
removeSignalCleanupHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForCouchdbStable(hostname: string, user: string, password: string): Promise<void> {
|
||||
const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
|
||||
const auth = btoa(`${user}:${password}`);
|
||||
const headers = { Authorization: `Basic ${auth}` };
|
||||
let consecutive = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const r = await fetch(`${h}/_up`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (r.ok) {
|
||||
consecutive++;
|
||||
if (consecutive >= 3) return;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
} catch {
|
||||
consecutive = 0;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error("CouchDB did not become stable in time");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch with retry (mirrors cli_test_curl_json() retry loop)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
retries = 30,
|
||||
delayMs = 2000,
|
||||
allowStatus: number[] = []
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
let lastStatus: number | undefined;
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
...init,
|
||||
});
|
||||
lastStatus = r.status;
|
||||
await r.body?.cancel().catch(() => {});
|
||||
if (r.ok || allowStatus.includes(r.status)) return;
|
||||
lastError = `HTTP ${r.status}`;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
await sleep(delayMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not reach ${url} after ${retries} retries: ${lastError} (last status: ${lastStatus ?? "N/A"})`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CouchDB
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// TODO: these values could be configurable via environment variables.
|
||||
//
|
||||
const COUCHDB_CONTAINER = "couchdb-test";
|
||||
const COUCHDB_IMAGE = "couchdb:3.5.0";
|
||||
|
||||
const MINIO_CONTAINER = "minio-test";
|
||||
// RustFS provides the S3 backend for the existing MINIO test mode.
|
||||
const S3_IMAGE = "rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035";
|
||||
const S3_CLIENT_IMAGE = "rustfs/rc:v0.1.35@sha256:adb45b56539006120f1d790bcc17ee5f9b4d93c1d7e71ed0a24f10267f9d6914";
|
||||
const S3_BUCKET_CORS = `<CORSConfiguration>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
<AllowedMethod>HEAD</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<AllowedHeader>authorization</AllowedHeader>
|
||||
<ExposeHeader>ETag</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>`;
|
||||
|
||||
export async function stopCouchdb(): Promise<void> {
|
||||
await stopAndRemoveContainer(COUCHDB_CONTAINER);
|
||||
untrackContainer(COUCHDB_CONTAINER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a CouchDB test container, initialise it, and create the test DB.
|
||||
* Mirrors cli_test_start_couchdb() from test-helpers.sh, using direct
|
||||
* docker / fetch calls instead of the bash util scripts.
|
||||
*/
|
||||
export async function startCouchdb(couchdbUri: string, user: string, password: string, dbname: string): Promise<void> {
|
||||
console.log("[INFO] stopping leftover CouchDB container if present");
|
||||
await stopCouchdb().catch(() => {});
|
||||
|
||||
console.log("[INFO] starting CouchDB test container");
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
COUCHDB_CONTAINER,
|
||||
"-p",
|
||||
// TODO: port mapping should be configurable.
|
||||
"5989:5984",
|
||||
"-e",
|
||||
`COUCHDB_USER=${user}`,
|
||||
"-e",
|
||||
`COUCHDB_PASSWORD=${password}`,
|
||||
"-e",
|
||||
"COUCHDB_SINGLE_NODE=y",
|
||||
COUCHDB_IMAGE
|
||||
);
|
||||
trackContainer(COUCHDB_CONTAINER);
|
||||
|
||||
console.log("[INFO] initialising CouchDB");
|
||||
await initCouchdb(couchdbUri, user, password);
|
||||
|
||||
console.log("[INFO] waiting for CouchDB to become stable");
|
||||
await waitForCouchdbStable(couchdbUri, user, password);
|
||||
|
||||
console.log(`[INFO] creating test database: ${dbname}`);
|
||||
await createCouchdbDatabase(couchdbUri, user, password, dbname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror couchdb-init.sh: configure single-node CouchDB via its REST API.
|
||||
*/
|
||||
async function initCouchdb(hostname: string, user: string, password: string, node = "_local"): Promise<void> {
|
||||
// Podman environments often resolve localhost to ::1; use 127.0.0.1 like
|
||||
// the bash script does.
|
||||
const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
|
||||
const auth = btoa(`${user}:${password}`);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${auth}`,
|
||||
};
|
||||
|
||||
const calls: Array<[string, string, string]> = [
|
||||
[
|
||||
"POST",
|
||||
`${h}/_cluster_setup`,
|
||||
JSON.stringify({
|
||||
action: "enable_single_node",
|
||||
username: user,
|
||||
password,
|
||||
bind_address: "0.0.0.0",
|
||||
port: 5984,
|
||||
singlenode: true,
|
||||
}),
|
||||
],
|
||||
["PUT", `${h}/_node/${node}/_config/chttpd/require_valid_user`, '"true"'],
|
||||
["PUT", `${h}/_node/${node}/_config/chttpd_auth/require_valid_user`, '"true"'],
|
||||
["PUT", `${h}/_node/${node}/_config/httpd/WWW-Authenticate`, '"Basic realm=\\"couchdb\\""'],
|
||||
["PUT", `${h}/_node/${node}/_config/httpd/enable_cors`, '"true"'],
|
||||
["PUT", `${h}/_node/${node}/_config/chttpd/enable_cors`, '"true"'],
|
||||
["PUT", `${h}/_node/${node}/_config/chttpd/max_http_request_size`, '"4294967296"'],
|
||||
["PUT", `${h}/_node/${node}/_config/couchdb/max_document_size`, '"50000000"'],
|
||||
["PUT", `${h}/_node/${node}/_config/cors/credentials`, '"true"'],
|
||||
["PUT", `${h}/_node/${node}/_config/cors/origins`, '"*"'],
|
||||
];
|
||||
|
||||
for (const [method, url, body] of calls) {
|
||||
await fetchRetry(url, { method, headers, body });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCouchdbDatabase(
|
||||
hostname: string,
|
||||
user: string,
|
||||
password: string,
|
||||
dbname: string
|
||||
): Promise<void> {
|
||||
const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
|
||||
const auth = btoa(`${user}:${password}`);
|
||||
await fetchRetry(`${h}/${dbname}`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Basic ${auth}` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a CouchDB document via PUT. Returns the updated document. */
|
||||
export async function updateCouchdbDoc(
|
||||
hostname: string,
|
||||
user: string,
|
||||
password: string,
|
||||
docUrl: string,
|
||||
updater: (doc: Record<string, unknown>) => Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const h = hostname.replace(/\/$/, "").replace("localhost", "127.0.0.1");
|
||||
const auth = btoa(`${user}:${password}`);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${auth}`,
|
||||
};
|
||||
const getRes = await fetch(`${h}/${docUrl}`, { headers });
|
||||
const current = (await getRes.json()) as Record<string, unknown>;
|
||||
const updated = updater(current);
|
||||
await fetchRetry(`${h}/${docUrl}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 (RustFS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.split("'").join(`'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export async function stopMinio(): Promise<void> {
|
||||
await stopAndRemoveContainer(MINIO_CONTAINER);
|
||||
untrackContainer(MINIO_CONTAINER);
|
||||
}
|
||||
|
||||
async function initMinioBucket(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string
|
||||
): Promise<boolean> {
|
||||
const cmd =
|
||||
`rc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`rc mb --ignore-existing myminio/${shQuote(bucket)} >/dev/null 2>&1 && ` +
|
||||
`printf %s ${shQuote(S3_BUCKET_CORS)} | rc cors set myminio/${shQuote(bucket)} - >/dev/null 2>&1`;
|
||||
const r = await docker("run", "--rm", "--network", "host", "--entrypoint", "/bin/sh", S3_CLIENT_IMAGE, "-c", cmd);
|
||||
return r.code === 0;
|
||||
}
|
||||
|
||||
async function waitForMinioBucket(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const checkCmd =
|
||||
`rc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`rc ls myminio/${shQuote(bucket)} >/dev/null 2>&1`;
|
||||
const check = await docker(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
// Now I used host networking to access the container via localhost for some environments (Docker Desktop on Windows).
|
||||
// We need something good idea to work across all environments.
|
||||
"host",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
S3_CLIENT_IMAGE,
|
||||
"-c",
|
||||
checkCmd
|
||||
);
|
||||
if (check.code === 0) {
|
||||
return;
|
||||
}
|
||||
await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error(`S3 bucket not ready: ${bucket}`);
|
||||
}
|
||||
|
||||
export async function startMinio(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string
|
||||
): Promise<void> {
|
||||
console.log("[INFO] stopping leftover S3 test container if present");
|
||||
await stopMinio().catch(() => {});
|
||||
|
||||
console.log("[INFO] starting RustFS test container");
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
MINIO_CONTAINER,
|
||||
// TODO: Ports should be configurable.
|
||||
"-p",
|
||||
"9000:9000",
|
||||
"-p",
|
||||
"9001:9001",
|
||||
"-e",
|
||||
`RUSTFS_ACCESS_KEY=${accessKey}`,
|
||||
"-e",
|
||||
`RUSTFS_SECRET_KEY=${secretKey}`,
|
||||
"-e",
|
||||
"RUSTFS_CONSOLE_ENABLE=true",
|
||||
"-e",
|
||||
"RUSTFS_CORS_ALLOWED_ORIGINS=*",
|
||||
S3_IMAGE,
|
||||
"/data"
|
||||
);
|
||||
trackContainer(MINIO_CONTAINER);
|
||||
|
||||
console.log(`[INFO] initialising S3 test bucket: ${bucket}`);
|
||||
let initialised = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (await initMinioBucket(minioEndpoint, accessKey, secretKey, bucket)) {
|
||||
initialised = true;
|
||||
break;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
if (!initialised) {
|
||||
throw new Error(`Could not initialise S3 bucket after retries: ${bucket}`);
|
||||
}
|
||||
|
||||
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P relay (strfry)
|
||||
// ---------------------------------------------------------------------------
|
||||
// TODO: these values could be configurable via environment variables.
|
||||
const P2P_RELAY_CONTAINER = "relay-test";
|
||||
const P2P_RELAY_IMAGE = "ghcr.io/hoytech/strfry:latest";
|
||||
const STRFRY_BOOTSTRAP_SH = String.raw`cat > /tmp/strfry.conf <<"EOF"
|
||||
db = "./strfry-db/"
|
||||
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
nofiles = 100000
|
||||
|
||||
info {
|
||||
name = "livesync test relay"
|
||||
description = "local relay for livesync p2p tests"
|
||||
}
|
||||
|
||||
maxWebsocketPayloadSize = 131072
|
||||
autoPingSeconds = 55
|
||||
|
||||
writePolicy {
|
||||
plugin = ""
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exec /app/strfry --config /tmp/strfry.conf relay`;
|
||||
|
||||
export async function stopP2pRelay(): Promise<void> {
|
||||
await stopAndRemoveContainer(P2P_RELAY_CONTAINER);
|
||||
untrackContainer(P2P_RELAY_CONTAINER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the local P2P relay container through the same docker runner used
|
||||
* by CouchDB helpers. This keeps process ownership consistent across
|
||||
* start/stop on Windows, WSL, and native Linux/macOS.
|
||||
*/
|
||||
export async function startP2pRelay(): Promise<void> {
|
||||
console.log("[INFO] stopping leftover P2P relay container if present");
|
||||
await stopP2pRelay().catch(() => {});
|
||||
|
||||
console.log("[INFO] starting local P2P relay container");
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
P2P_RELAY_CONTAINER,
|
||||
"-p",
|
||||
//TODO: port mapping should be configurable.
|
||||
"4000:7777",
|
||||
"--tmpfs",
|
||||
"/app/strfry-db:rw,size=256m,mode=1777",
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
P2P_RELAY_IMAGE,
|
||||
"-lc",
|
||||
STRFRY_BOOTSTRAP_SH
|
||||
);
|
||||
trackContainer(P2P_RELAY_CONTAINER);
|
||||
}
|
||||
|
||||
export function isLocalP2pRelay(relayUrl: string): boolean {
|
||||
return relayUrl.includes("localhost") || relayUrl.includes("127.0.0.1") || relayUrl.includes("[::1]");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coturn (STUN/TURN)
|
||||
// ---------------------------------------------------------------------------
|
||||
const COTURN_CONTAINER = "coturn-test";
|
||||
const COTURN_IMAGE = "coturn/coturn:latest";
|
||||
|
||||
export async function stopCoturn(): Promise<void> {
|
||||
await stopAndRemoveContainer(COTURN_CONTAINER);
|
||||
untrackContainer(COTURN_CONTAINER);
|
||||
}
|
||||
|
||||
export async function startCoturn(
|
||||
port = 3478,
|
||||
user = "testuser",
|
||||
pass = "testpass",
|
||||
realm = "livesync.test"
|
||||
): Promise<void> {
|
||||
console.log("[INFO] stopping leftover Coturn container if present");
|
||||
await stopCoturn().catch(() => {});
|
||||
|
||||
const { getOptimalLoopbackIp } = await import("./net.ts");
|
||||
const externalIp = await getOptimalLoopbackIp();
|
||||
|
||||
console.log(`[INFO] starting local Coturn container with external-ip ${externalIp}`);
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
COTURN_CONTAINER,
|
||||
"-p",
|
||||
`${port}:${port}`,
|
||||
"-p",
|
||||
`${port}:${port}/udp`,
|
||||
COTURN_IMAGE,
|
||||
"--log-file=stdout",
|
||||
`--external-ip=${externalIp}`,
|
||||
`--user=${user}:${pass}`,
|
||||
`--realm=${realm}`
|
||||
);
|
||||
trackContainer(COTURN_CONTAINER);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
/**
|
||||
* Load a .env-style file (KEY=value per line) into a plain object.
|
||||
* Equivalent to `source $TEST_ENV_FILE; set -a` in bash.
|
||||
* Maybe we should use some library... now it is just the minimal implementation that covers our use cases.
|
||||
*
|
||||
* Supported value formats:
|
||||
* KEY=value
|
||||
* KEY='single quoted'
|
||||
* KEY="double quoted"
|
||||
* # comment lines are ignored
|
||||
*/
|
||||
export async function loadEnvFile(filePath: string): Promise<Record<string, string>> {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const raw = trimmed.slice(idx + 1).trim();
|
||||
// Strip surrounding single or double quotes
|
||||
result[key] = raw.replace(/^(['"])(.*)\1$/, "$2");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { runProcess } from "./process.ts";
|
||||
import { CLI_DIR, CLI_DIST } from "./cli.ts";
|
||||
|
||||
export type CliProcessMeasurement = {
|
||||
elapsedMs: number;
|
||||
userCpuMs: number;
|
||||
systemCpuMs: number;
|
||||
totalCpuMs: number;
|
||||
cpuToWallRatio: number;
|
||||
maxResidentSetKiB: number;
|
||||
};
|
||||
|
||||
const MARKER = "__LIVESYNC_GNU_TIME__";
|
||||
|
||||
export async function runMeasuredCliOrFail(...args: string[]): Promise<CliProcessMeasurement> {
|
||||
const started = performance.now();
|
||||
let output: Awaited<ReturnType<typeof runProcess>>;
|
||||
try {
|
||||
output = await runProcess("/usr/bin/time", {
|
||||
args: ["-f", `${MARKER}%U\t%S\t%M`, process.execPath, CLI_DIST, ...args],
|
||||
cwd: CLI_DIR,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
||||
throw new Error(
|
||||
"GNU /usr/bin/time is required for the compression benchmark. Use the Compose runner or install GNU time."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const elapsedMs = performance.now() - started;
|
||||
const stderr = new TextDecoder().decode(output.stderr);
|
||||
const stdout = new TextDecoder().decode(output.stdout);
|
||||
const measurementLine = stderr.split(/\r?\n/).find((line) => line.startsWith(MARKER));
|
||||
if (!output.success) {
|
||||
throw new Error(`CLI exited with code ${output.code}\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
}
|
||||
if (!measurementLine) {
|
||||
throw new Error(`GNU time did not emit the expected measurement marker\nstderr: ${stderr}`);
|
||||
}
|
||||
const [userSeconds, systemSeconds, maxResidentSetKiB] = measurementLine
|
||||
.slice(MARKER.length)
|
||||
.split("\t")
|
||||
.map(Number);
|
||||
if (![userSeconds, systemSeconds, maxResidentSetKiB].every(Number.isFinite)) {
|
||||
throw new Error(`Could not parse GNU time measurement: ${measurementLine}`);
|
||||
}
|
||||
const userCpuMs = userSeconds * 1000;
|
||||
const systemCpuMs = systemSeconds * 1000;
|
||||
const totalCpuMs = userCpuMs + systemCpuMs;
|
||||
return {
|
||||
elapsedMs: Number(elapsedMs.toFixed(1)),
|
||||
userCpuMs: Number(userCpuMs.toFixed(1)),
|
||||
systemCpuMs: Number(systemCpuMs.toFixed(1)),
|
||||
totalCpuMs: Number(totalCpuMs.toFixed(1)),
|
||||
cpuToWallRatio: Number((totalCpuMs / elapsedMs).toFixed(4)),
|
||||
maxResidentSetKiB,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createConnection, createServer } from "node:net";
|
||||
|
||||
type WaitForPortOptions = { timeoutMs?: number; intervalMs?: number; connectTimeoutMs?: number };
|
||||
|
||||
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = createConnection({ host: hostname, port });
|
||||
socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`connect timeout after ${timeoutMs}ms`)));
|
||||
socket.once("error", reject);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPort(hostname: string, port: number, options: WaitForPortOptions = {}): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? 15000;
|
||||
const intervalMs = options.intervalMs ?? 250;
|
||||
const started = Date.now();
|
||||
let lastError: unknown;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
await connectWithTimeout(hostname, port, options.connectTimeoutMs ?? 1000);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Port ${hostname}:${port} did not become ready within ${timeoutMs}ms` +
|
||||
(lastError ? ` (last error: ${String(lastError)})` : "")
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFreePort(hostname = "127.0.0.1"): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, hostname, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
if (!address || typeof address === "string") throw new Error("The listener did not bind a TCP port");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
export async function getOptimalLoopbackIp(): Promise<string> {
|
||||
for (const hostname of ["127.0.0.1", "::1"]) {
|
||||
try {
|
||||
await getFreePort(hostname);
|
||||
return hostname;
|
||||
} catch {
|
||||
/* Try the other loopback family. */
|
||||
}
|
||||
}
|
||||
return "127.0.0.1";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
import { runCli } from "./cli.ts";
|
||||
import { isLocalP2pRelay, startP2pRelay, stopP2pRelay, startCoturn, stopCoturn } from "./docker.ts";
|
||||
import { waitForPort } from "./net.ts";
|
||||
|
||||
export type PeerEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseRelayEndpoint(relay: string): { hostname: string; port: number } {
|
||||
const url = new URL(relay);
|
||||
const port = url.port ? Number(url.port) : url.protocol === "ws:" ? 80 : url.protocol === "wss:" ? 443 : NaN;
|
||||
if (!Number.isFinite(port)) {
|
||||
throw new Error(`Unsupported relay URL: ${relay}`);
|
||||
}
|
||||
const hostname = url.hostname === "localhost" ? "127.0.0.1" : url.hostname;
|
||||
return { hostname, port };
|
||||
}
|
||||
|
||||
export function parsePeerLines(output: string): PeerEntry[] {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.split("\t"))
|
||||
.filter((parts) => parts.length >= 3 && parts[0] === "[peer]")
|
||||
.map((parts) => ({ id: parts[1], name: parts[2] }));
|
||||
}
|
||||
|
||||
export async function discoverPeer(
|
||||
vaultDir: string,
|
||||
settingsFile: string,
|
||||
timeoutSeconds: number,
|
||||
targetPeer?: string
|
||||
): Promise<PeerEntry> {
|
||||
const retries = Math.max(0, Number(process.env.LIVESYNC_P2P_PEERS_RETRY ?? "3"));
|
||||
let lastCombined = "";
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const result = await runCli(vaultDir, "--settings", settingsFile, "p2p-peers", String(timeoutSeconds));
|
||||
lastCombined = result.combined;
|
||||
|
||||
if (result.code === 0) {
|
||||
const peers = parsePeerLines(result.stdout);
|
||||
if (targetPeer) {
|
||||
const matched = peers.find((peer) => peer.id === targetPeer || peer.name === targetPeer);
|
||||
if (matched) return matched;
|
||||
}
|
||||
if (peers.length > 0) {
|
||||
return peers[0];
|
||||
}
|
||||
|
||||
const fallback = result.combined.match(/Advertisement from\s+([^\s]+)/);
|
||||
if (fallback?.[1]) {
|
||||
return { id: fallback[1], name: fallback[1] };
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
const waitMs = 400 * (attempt + 1);
|
||||
console.warn(
|
||||
`[WARN] p2p-peers returned no usable peers, retrying (${attempt + 1}/${retries}) in ${waitMs}ms`
|
||||
);
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
result.code !== 0 ? `p2p-peers failed\n${result.combined}` : `No peers discovered\n${result.combined}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`No peers discovered\n${lastCombined}`);
|
||||
}
|
||||
|
||||
export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
|
||||
const shouldStart = isLocalP2pRelay(relay);
|
||||
if (shouldStart) {
|
||||
await startP2pRelay();
|
||||
}
|
||||
const endpoint = parseRelayEndpoint(relay);
|
||||
await waitForPort(endpoint.hostname, endpoint.port, {
|
||||
timeoutMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS ?? "15000"),
|
||||
intervalMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_INTERVAL_MS ?? "250"),
|
||||
connectTimeoutMs: Number(process.env.LIVESYNC_P2P_RELAY_CONNECT_TIMEOUT_MS ?? "1000"),
|
||||
});
|
||||
// Docker proxy accepts TCP connections instantly before the container's internal process is fully ready.
|
||||
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
|
||||
if (shouldStart) {
|
||||
await sleep(3000);
|
||||
}
|
||||
return shouldStart;
|
||||
}
|
||||
|
||||
export async function stopLocalRelayIfStarted(started: boolean): Promise<void> {
|
||||
if (started) {
|
||||
await stopP2pRelay().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeStartCoturn(turnServers: string): Promise<boolean> {
|
||||
if (turnServers.includes("localhost") || turnServers.includes("127.0.0.1") || turnServers.includes("[::1]")) {
|
||||
await startCoturn();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function stopCoturnIfStarted(started: boolean): Promise<void> {
|
||||
if (started) {
|
||||
await stopCoturn().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export const REPOSITORY_ROOT = resolve(import.meta.dirname, "../../..");
|
||||
export const CLI_DIR = join(REPOSITORY_ROOT, "src", "apps", "cli");
|
||||
export const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { runProcess, spawnProcess } from "./process.ts";
|
||||
|
||||
test("subprocesses preserve arguments, environment overrides, and both output streams", async () => {
|
||||
const argument = "a path with spaces/日本語.txt";
|
||||
const result = await runProcess(process.execPath, {
|
||||
args: [
|
||||
"--eval",
|
||||
"process.stdout.write(JSON.stringify({arg:process.argv[1],value:process.env.LIVESYNC_PROCESS_TEST,inherited:process.env.PATH||process.env.Path}));process.stderr.write('diagnostic');process.exitCode=7",
|
||||
argument,
|
||||
],
|
||||
env: { LIVESYNC_PROCESS_TEST: "override" },
|
||||
});
|
||||
assert.equal(result.code, 7);
|
||||
assert.equal(result.success, false);
|
||||
const output = JSON.parse(new TextDecoder().decode(result.stdout));
|
||||
assert.equal(output.arg, argument);
|
||||
assert.equal(output.value, "override");
|
||||
assert.ok(output.inherited);
|
||||
assert.equal(new TextDecoder().decode(result.stderr), "diagnostic");
|
||||
});
|
||||
|
||||
test("piped stdin closes and large stdout and stderr drain before completion", async () => {
|
||||
const child = spawnProcess(process.execPath, {
|
||||
args: [
|
||||
"--eval",
|
||||
"let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',v=>data+=v);process.stdin.on('end',()=>{process.stdout.write(data.repeat(100000));process.stderr.write('e'.repeat(100000));})",
|
||||
],
|
||||
stdin: "piped",
|
||||
});
|
||||
const stdout = new Response(child.stdout).text();
|
||||
const stderr = new Response(child.stderr).text();
|
||||
const writer = child.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode("abc"));
|
||||
await writer.close();
|
||||
const [status, out, err] = await Promise.all([child.status, stdout, stderr]);
|
||||
assert.equal(status.success, true);
|
||||
assert.equal(out, "abc".repeat(100000));
|
||||
assert.equal(err, "e".repeat(100000));
|
||||
});
|
||||
|
||||
test("a missing executable rejects with its OS error", async () => {
|
||||
await assert.rejects(runProcess("livesync-missing-test-executable-847281"), { code: "ENOENT" });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { constants } from "node:os";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
|
||||
export type ProcessStatus = { code: number; success: boolean; signal: NodeJS.Signals | null };
|
||||
export type ProcessOutput = ProcessStatus & { stdout: Uint8Array; stderr: Uint8Array };
|
||||
export type ProcessOptions = {
|
||||
args?: string[];
|
||||
cwd?: string | URL;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: "null" | "piped" | "inherit";
|
||||
stdout?: "piped" | "inherit";
|
||||
stderr?: "piped" | "inherit";
|
||||
};
|
||||
export type RunningProcess = {
|
||||
readonly pid: number;
|
||||
readonly stdin: WritableStream<Uint8Array>;
|
||||
readonly stdout: ReadableStream<Uint8Array>;
|
||||
readonly stderr: ReadableStream<Uint8Array>;
|
||||
readonly status: Promise<ProcessStatus>;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
};
|
||||
|
||||
export function spawnProcess(command: string, options: ProcessOptions = {}): RunningProcess {
|
||||
const child = spawn(command, options.args ?? [], {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: [
|
||||
options.stdin === "inherit" ? "inherit" : options.stdin === "piped" ? "pipe" : "ignore",
|
||||
options.stdout === "inherit" ? "inherit" : "pipe",
|
||||
options.stderr === "inherit" ? "inherit" : "pipe",
|
||||
],
|
||||
windowsHide: true,
|
||||
});
|
||||
const status = new Promise<ProcessStatus>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) =>
|
||||
resolve({
|
||||
code: code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1),
|
||||
success: code === 0 && signal === null,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
});
|
||||
return {
|
||||
pid: child.pid ?? 0,
|
||||
get stdin() {
|
||||
if (!child.stdin) throw new Error("The child process stdin is not piped");
|
||||
return Writable.toWeb(child.stdin) as WritableStream<Uint8Array>;
|
||||
},
|
||||
get stdout() {
|
||||
if (!child.stdout) throw new Error("The child process stdout is not piped");
|
||||
return Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
|
||||
},
|
||||
get stderr() {
|
||||
if (!child.stderr) throw new Error("The child process stderr is not piped");
|
||||
return Readable.toWeb(child.stderr) as ReadableStream<Uint8Array>;
|
||||
},
|
||||
status,
|
||||
kill(signal = "SIGTERM") {
|
||||
child.kill(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProcess(command: string, options: ProcessOptions = {}): Promise<ProcessOutput> {
|
||||
const child = spawnProcess(command, options);
|
||||
const collect = async (stream: ReadableStream<Uint8Array>): Promise<Uint8Array> =>
|
||||
new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.status,
|
||||
options.stdout === "inherit" ? new Uint8Array() : collect(child.stdout),
|
||||
options.stderr === "inherit" ? new Uint8Array() : collect(child.stderr),
|
||||
]);
|
||||
return { ...status, stdout, stderr };
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { runProcess } from "./process.ts";
|
||||
import { CLI_DIR, runCliOrFail } from "./cli.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings file initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a default settings file using the CLI's init-settings command. */
|
||||
export async function initSettingsFile(settingsFile: string): Promise<void> {
|
||||
await runCliOrFail("init-settings", "--force", settingsFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a full setup URI from a settings file via the Commonlib package API.
|
||||
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
|
||||
*/
|
||||
export async function generateSetupUriFromSettings(
|
||||
settingsFile: string,
|
||||
setupPassphrase: string,
|
||||
preserveRemoteSettings = false
|
||||
): Promise<string> {
|
||||
const script = [
|
||||
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
|
||||
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
|
||||
"(async () => {",
|
||||
" const settingsPath = process.env.SETTINGS_FILE;",
|
||||
" const passphrase = process.env.SETUP_PASSPHRASE;",
|
||||
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
|
||||
...(preserveRemoteSettings
|
||||
? []
|
||||
: [
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
]),
|
||||
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
|
||||
" process.stdout.write(uri.trim());",
|
||||
"})();",
|
||||
].join("\n");
|
||||
|
||||
const { code, stdout, stderr } = await runProcess(process.execPath, {
|
||||
args: ["--input-type=module", "--eval", script],
|
||||
cwd: CLI_DIR,
|
||||
env: { SETTINGS_FILE: settingsFile, SETUP_PASSPHRASE: setupPassphrase },
|
||||
});
|
||||
const dec = new TextDecoder();
|
||||
if (code !== 0) {
|
||||
throw new Error(
|
||||
`Failed to generate setup URI (code ${code})\nstdout: ${dec.decode(stdout)}\nstderr: ${dec.decode(stderr)}`
|
||||
);
|
||||
}
|
||||
const uri = dec.decode(stdout).trim();
|
||||
if (!uri) throw new Error("Failed to generate setup URI: output is empty");
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Set isConfigured=true in a settings file (required for mirror / scan). */
|
||||
export async function markSettingsConfigured(settingsFile: string): Promise<void> {
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
data.isConfigured = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CouchDB remote settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply CouchDB connection details to a settings file.
|
||||
* Mirrors cli_test_apply_couchdb_settings() from test-helpers.sh.
|
||||
*/
|
||||
export async function applyCouchdbSettings(
|
||||
settingsFile: string,
|
||||
couchdbUri: string,
|
||||
couchdbUser: string,
|
||||
couchdbPassword: string,
|
||||
couchdbDbname: string,
|
||||
liveSync = false
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
data.couchDB_URI = couchdbUri;
|
||||
data.couchDB_USER = couchdbUser;
|
||||
data.couchDB_PASSWORD = couchdbPassword;
|
||||
data.couchDB_DBNAME = couchdbDbname;
|
||||
if (liveSync) {
|
||||
data.liveSync = true;
|
||||
data.syncOnStart = false;
|
||||
data.syncOnSave = false;
|
||||
data.usePluginSync = false;
|
||||
}
|
||||
data.isConfigured = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export async function applyRemoteSyncSettings(
|
||||
settingsFile: string,
|
||||
options: {
|
||||
remoteType: "COUCHDB" | "MINIO";
|
||||
couchdbUri?: string;
|
||||
couchdbUser?: string;
|
||||
couchdbPassword?: string;
|
||||
couchdbDbname?: string;
|
||||
minioBucket?: string;
|
||||
minioEndpoint?: string;
|
||||
minioAccessKey?: string;
|
||||
minioSecretKey?: string;
|
||||
encrypt?: boolean;
|
||||
passphrase?: string;
|
||||
enableCompression?: boolean;
|
||||
usePathObfuscation?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
|
||||
if (options.remoteType === "COUCHDB") {
|
||||
data.remoteType = "";
|
||||
data.couchDB_URI = options.couchdbUri;
|
||||
data.couchDB_USER = options.couchdbUser;
|
||||
data.couchDB_PASSWORD = options.couchdbPassword;
|
||||
data.couchDB_DBNAME = options.couchdbDbname;
|
||||
} else {
|
||||
data.remoteType = "MINIO";
|
||||
data.bucket = options.minioBucket;
|
||||
data.endpoint = options.minioEndpoint;
|
||||
data.accessKey = options.minioAccessKey;
|
||||
data.secretKey = options.minioSecretKey;
|
||||
data.region = "auto";
|
||||
data.forcePathStyle = true;
|
||||
}
|
||||
|
||||
data.liveSync = true;
|
||||
data.syncOnStart = false;
|
||||
data.syncOnSave = false;
|
||||
data.usePluginSync = false;
|
||||
data.encrypt = options.encrypt === true;
|
||||
data.passphrase = options.encrypt ? (options.passphrase ?? "") : "";
|
||||
if (options.enableCompression !== undefined) {
|
||||
data.enableCompression = options.enableCompression;
|
||||
}
|
||||
if (options.usePathObfuscation !== undefined) {
|
||||
data.usePathObfuscation = options.usePathObfuscation;
|
||||
}
|
||||
data.isConfigured = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply P2P connection details to a settings file.
|
||||
* Mirrors cli_test_apply_p2p_settings() from test-helpers.sh.
|
||||
*/
|
||||
export async function applyP2pSettings(
|
||||
settingsFile: string,
|
||||
roomId: string,
|
||||
passphrase: string,
|
||||
appId = "self-hosted-livesync-cli-tests",
|
||||
relays = "ws://localhost:4000/",
|
||||
autoAccept = "~.*",
|
||||
turnServers = "turn:127.0.0.1:3478"
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
data.P2P_Enabled = true;
|
||||
data.P2P_AutoStart = false;
|
||||
data.P2P_AutoBroadcast = false;
|
||||
data.P2P_AppID = appId;
|
||||
data.P2P_roomID = roomId;
|
||||
data.P2P_passphrase = passphrase;
|
||||
data.P2P_relays = relays;
|
||||
data.P2P_AutoAcceptingPeers = autoAccept;
|
||||
data.P2P_AutoDenyingPeers = "";
|
||||
data.P2P_turnServers = turnServers;
|
||||
data.P2P_turnUsername = "testuser";
|
||||
data.P2P_turnCredential = "testpass";
|
||||
data.P2P_IsHeadless = true;
|
||||
data.isConfigured = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export async function applyP2pTestTweaks(settingsFile: string, deviceName: string, passphrase: string): Promise<void> {
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
data.remoteType = "ONLY_P2P";
|
||||
data.encrypt = true;
|
||||
data.passphrase = passphrase;
|
||||
data.usePathObfuscation = true;
|
||||
data.handleFilenameCaseSensitive = false;
|
||||
data.customChunkSize = 60;
|
||||
data.usePluginSyncV2 = true;
|
||||
data.P2P_DevicePeerName = deviceName;
|
||||
data.isConfigured = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** A temporary directory which is removed when its `await using` scope ends. */
|
||||
export class TempDir implements AsyncDisposable {
|
||||
readonly path: string;
|
||||
private constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
static async create(prefix = "livesync-node-test"): Promise<TempDir> {
|
||||
return new TempDir(await mkdtemp(join(tmpdir(), `${prefix}.`)));
|
||||
}
|
||||
join(...parts: string[]): string {
|
||||
return join(this.path, ...parts);
|
||||
}
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await rm(this.path, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { RTCPeerConnection } from "werift";
|
||||
import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
|
||||
const requireFromProbe = createRequire(import.meta.url);
|
||||
const commonlibEntry = requireFromProbe.resolve("@vrtmrz/livesync-commonlib/context");
|
||||
const requireFromCommonlib = createRequire(commonlibEntry);
|
||||
const nostrEntry = requireFromCommonlib.resolve("@trystero-p2p/nostr");
|
||||
const { getRelaySockets, joinRoom, pauseRelayReconnection } = await import(pathToFileURL(nostrEntry).href);
|
||||
|
||||
const relayUrl = process.env.RELAY ?? "ws://nostr-relay:7777/";
|
||||
const timeoutMs = Number(process.env.RELAY_TIMEOUT_MS ?? 15_000);
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitFor(description, predicate) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (predicate()) return;
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
|
||||
async function waitForSocketOpen(socket, description) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
// Node can expose OPEN before it dispatches the event. Yield once so
|
||||
// Trystero's previously registered onopen handler has completed before
|
||||
// this probe starts the close handshake.
|
||||
await delay(0);
|
||||
if (socket.readyState === WebSocket.OPEN) return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`Timed out waiting for ${description}; readyState=${socket.readyState}`));
|
||||
}, timeoutMs);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeEventListener("open", onOpen);
|
||||
socket.removeEventListener("close", onClose);
|
||||
socket.removeEventListener("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(new Error(`${description} closed before opening`));
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error(`${description} failed before opening`));
|
||||
};
|
||||
|
||||
// Trystero installs its onopen handler while constructing the socket,
|
||||
// before this observer is registered. Waiting for the actual event
|
||||
// therefore establishes transport readiness without a fixed delay.
|
||||
socket.addEventListener("open", onOpen, { once: true });
|
||||
socket.addEventListener("close", onClose, { once: true });
|
||||
socket.addEventListener("error", onError, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const room = joinRoom(
|
||||
{
|
||||
appId: `livesync-relay-disconnect-probe-${Date.now()}`,
|
||||
password: "local-test-only",
|
||||
relayConfig: {
|
||||
urls: [relayUrl],
|
||||
manualReconnection: true,
|
||||
},
|
||||
rtcPolyfill: RTCPeerConnection,
|
||||
},
|
||||
"disconnect-probe"
|
||||
);
|
||||
|
||||
try {
|
||||
await waitFor("the relay WebSocket to be registered", () => Object.values(getRelaySockets()).length > 0);
|
||||
const originalSockets = Object.values(getRelaySockets());
|
||||
await Promise.all(
|
||||
originalSockets.map((socket, index) => waitForSocketOpen(socket, `relay WebSocket ${index + 1} to open`))
|
||||
);
|
||||
|
||||
const replicator = new TrysteroReplicator(
|
||||
{},
|
||||
{
|
||||
close: async () => undefined,
|
||||
dispatchConnectionStatus: async () => undefined,
|
||||
}
|
||||
);
|
||||
replicator.disconnectFromServer();
|
||||
|
||||
await waitFor("all relay WebSockets to close", () =>
|
||||
originalSockets.every((socket) => socket.readyState === WebSocket.CLOSED)
|
||||
);
|
||||
await delay(4_000);
|
||||
if (!originalSockets.every((socket) => socket.readyState === WebSocket.CLOSED)) {
|
||||
throw new Error("A relay WebSocket reconnected while reconnection was paused");
|
||||
}
|
||||
|
||||
replicator.allowReconnection();
|
||||
await waitFor("a replacement relay WebSocket to be registered", () =>
|
||||
Object.values(getRelaySockets()).some((socket) => !originalSockets.includes(socket))
|
||||
);
|
||||
const replacementSockets = Object.values(getRelaySockets()).filter((socket) => !originalSockets.includes(socket));
|
||||
await Promise.all(
|
||||
replacementSockets.map((socket, index) =>
|
||||
waitForSocketOpen(socket, `replacement relay WebSocket ${index + 1} to open`)
|
||||
)
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
socketsClosed: originalSockets.length,
|
||||
stayedDisconnectedWhilePaused: true,
|
||||
reconnectedAfterResume: true,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
await room.leave();
|
||||
pauseRelayReconnection();
|
||||
for (const socket of Object.values(getRelaySockets())) socket.close();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { spawnProcess } from "./helpers/process.ts";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import TASKS from "./ci-tasks.json" with { type: "json" };
|
||||
|
||||
for (const [index, task] of TASKS.entries()) {
|
||||
console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`);
|
||||
const child = spawnProcess(process.execPath, {
|
||||
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), task],
|
||||
cwd: import.meta.dirname,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const status = await child.status;
|
||||
if (!status.success) {
|
||||
console.error(`[CLI E2E] ${task} failed with exit code ${status.code}.`);
|
||||
process.exit(status.code);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[CLI E2E] CI suite passed (${TASKS.length} tasks).`);
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
TASK="${CLI_E2E_TASK:-test:p2p:ci}"
|
||||
|
||||
case "$TASK" in
|
||||
test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-automatic-host|test:p2p-replacement|test:p2p-relay-disconnect|test:p2p:ci|test:p2p-three-nodes|test:p2p-upload-download)
|
||||
exec node run-task.ts "$TASK"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown CLI_E2E_TASK: $TASK" >&2
|
||||
echo "Expected one of: test:p2p-host, test:p2p-peers, test:p2p-sync, test:p2p-automatic-host, test:p2p-replacement, test:p2p-relay-disconnect, test:p2p:ci, test:p2p-three-nodes, test:p2p-upload-download" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawnProcess, type ProcessStatus } from "./helpers/process.ts";
|
||||
import { REPOSITORY_ROOT } from "./helpers/paths.ts";
|
||||
import { realpath } from "node:fs/promises";
|
||||
const repositoryRoot = await realpath(REPOSITORY_ROOT);
|
||||
const composeArgs = ["compose", "-f", "test/bench-network/compose.yml"];
|
||||
const p2pEnvironment = {
|
||||
CLI_E2E_TASK: process.env.CLI_E2E_TASK ?? "test:p2p:ci",
|
||||
RELAY: process.env.RELAY ?? "ws://nostr-relay:7777/",
|
||||
PEERS_TIMEOUT: process.env.PEERS_TIMEOUT ?? "20",
|
||||
SYNC_TIMEOUT: process.env.SYNC_TIMEOUT ?? "60",
|
||||
LIVESYNC_USE_COTURN: process.env.LIVESYNC_USE_COTURN ?? "0",
|
||||
TURN_SERVERS: process.env.TURN_SERVERS ?? "none",
|
||||
LIVESYNC_P2P_PEERS_RETRY: process.env.LIVESYNC_P2P_PEERS_RETRY ?? "1",
|
||||
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: process.env.LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS ?? "60000",
|
||||
BENCH_LIVESYNC_TEST_TEE: process.env.BENCH_LIVESYNC_TEST_TEE ?? "0",
|
||||
LIVESYNC_CLI_DEBUG: process.env.LIVESYNC_CLI_DEBUG ?? "0",
|
||||
LIVESYNC_CLI_VERBOSE: process.env.LIVESYNC_CLI_VERBOSE ?? "0",
|
||||
};
|
||||
|
||||
async function runDocker(args: string[], env?: Record<string, string>): Promise<ProcessStatus> {
|
||||
return await spawnProcess("docker", {
|
||||
args,
|
||||
cwd: repositoryRoot,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).status;
|
||||
}
|
||||
|
||||
let testStatus: ProcessStatus | undefined;
|
||||
try {
|
||||
testStatus = await runDocker(
|
||||
[...composeArgs, "run", "--build", "--rm", "bench-runner", "run-livesync-cli-e2e"],
|
||||
p2pEnvironment
|
||||
);
|
||||
} finally {
|
||||
const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]);
|
||||
if (!cleanupStatus.success) {
|
||||
console.error(`[CLI E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`);
|
||||
if (testStatus?.success) {
|
||||
process.exit(cleanupStatus.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!testStatus?.success) {
|
||||
const code = testStatus?.code ?? 1;
|
||||
console.error(`[CLI E2E] Compose P2P suite failed with exit code ${code}.`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
console.log("\n[CLI E2E] Compose P2P suite passed.");
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnProcess } from "./helpers/process.ts";
|
||||
import { CLI_DIR, REPOSITORY_ROOT } from "./helpers/paths.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const reportDirectory = join(REPOSITORY_ROOT, "coverage", "cli-e2e");
|
||||
const task = process.argv[2] ?? "test:local";
|
||||
|
||||
async function run(args: string[], cwd: string, env: NodeJS.ProcessEnv = {}): Promise<void> {
|
||||
const result = await spawnProcess(process.execPath, {
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).status;
|
||||
if (!result.success) throw new Error(`Coverage command failed with exit code ${result.code}`);
|
||||
}
|
||||
|
||||
if (task === "test:p2p:compose" || task === "test:e2e:all") {
|
||||
throw new Error("CLI coverage collects host processes; select a host-direct task such as test:local or test:ci");
|
||||
}
|
||||
const viteCli = join(dirname(require.resolve("vite/package.json")), "bin", "vite.js");
|
||||
await run([viteCli, "build"], CLI_DIR, { LIVESYNC_CLI_COVERAGE: "1" });
|
||||
const c8Cli = require.resolve("c8/bin/c8.js");
|
||||
await run(
|
||||
[
|
||||
c8Cli,
|
||||
"--all",
|
||||
"--src=src/apps/cli",
|
||||
// Match native paths and Node's file URL source-map cache.
|
||||
"--include=**/src/**/*.ts",
|
||||
"--exclude-after-remap",
|
||||
"--exclude=**/test-support/**",
|
||||
"--exclude=**/test/**",
|
||||
"--exclude=**/*.unit.spec.ts",
|
||||
"--exclude=**/*.test.ts",
|
||||
"--reporter=text",
|
||||
"--reporter=json",
|
||||
"--reporter=html",
|
||||
`--reports-dir=${reportDirectory}`,
|
||||
`--temp-directory=${join(reportDirectory, "raw")}`,
|
||||
process.execPath,
|
||||
fileURLToPath(new URL("./run-task.ts", import.meta.url)),
|
||||
task,
|
||||
...process.argv.slice(3),
|
||||
],
|
||||
REPOSITORY_ROOT
|
||||
);
|
||||
await run(
|
||||
["--test", "--test-concurrency=1", fileURLToPath(new URL("./coverage-report.test.ts", import.meta.url))],
|
||||
REPOSITORY_ROOT,
|
||||
{
|
||||
LIVESYNC_CLI_COVERAGE_REPORT: join(reportDirectory, "coverage-final.json"),
|
||||
LIVESYNC_CLI_COVERAGE_TASK: task,
|
||||
}
|
||||
);
|
||||
console.log(`CLI E2E coverage: ${reportDirectory}`);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { CLI_DIR } from "./helpers/paths.ts";
|
||||
import { spawnProcess } from "./helpers/process.ts";
|
||||
|
||||
type Task = { test?: string[]; filter?: string; script?: string; tasks?: string[]; command?: string; args?: string[] };
|
||||
const taskDirectory = import.meta.dirname;
|
||||
|
||||
export async function runTask(name: string, extraArgs: string[] = []): Promise<number> {
|
||||
if (Number(process.versions.node.split(".")[0]) < 26) throw new Error("CLI E2E tests require Node.js 26 or newer");
|
||||
const tasks = JSON.parse(await readFile(new URL("./tasks.json", import.meta.url), "utf8")) as Record<string, Task>;
|
||||
const task = tasks[name];
|
||||
if (!task) throw new Error(`Unknown CLI task '${name}'. Available tasks: ${Object.keys(tasks).join(", ")}`);
|
||||
if (task.tasks) {
|
||||
for (const child of task.tasks) {
|
||||
const code = await runTask(child, extraArgs);
|
||||
if (code !== 0) return code;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const envFile = join(CLI_DIR, ".test.env");
|
||||
const envArgs = existsSync(envFile) ? [`--env-file=${envFile}`] : [];
|
||||
let command = process.execPath;
|
||||
let args: string[];
|
||||
if (task.test) {
|
||||
args = [
|
||||
...envArgs,
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
...(task.filter ? [`--test-name-pattern=${task.filter}`] : []),
|
||||
...extraArgs,
|
||||
...task.test.map((file) => fileURLToPath(new URL(file, import.meta.url))),
|
||||
];
|
||||
} else if (task.script) {
|
||||
args = [...envArgs, fileURLToPath(new URL(task.script, import.meta.url)), ...extraArgs];
|
||||
} else if (task.command) {
|
||||
command = task.command;
|
||||
args = [...(task.args ?? []), ...extraArgs];
|
||||
} else throw new Error(`CLI task '${name}' has no executable entry point`);
|
||||
const child = spawnProcess(command, {
|
||||
args,
|
||||
cwd: taskDirectory,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
return (await child.status).code;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
process.exitCode = await runTask(process.argv[2] ?? "test", process.argv.slice(3));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
{
|
||||
"test:helpers": {
|
||||
"test": [
|
||||
"helpers/process.test.ts"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"tasks": [
|
||||
"test:ci"
|
||||
]
|
||||
},
|
||||
"test:ci": {
|
||||
"script": "run-ci-suite.ts"
|
||||
},
|
||||
"test:p2p:compose": {
|
||||
"script": "run-compose-p2p.ts"
|
||||
},
|
||||
"test:local": {
|
||||
"tasks": [
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon"
|
||||
]
|
||||
},
|
||||
"test:daemon": {
|
||||
"test": [
|
||||
"test-daemon.ts"
|
||||
]
|
||||
},
|
||||
"test:daemon-startup": {
|
||||
"test": [
|
||||
"test-daemon-startup.ts"
|
||||
]
|
||||
},
|
||||
"test:decoupled-vault": {
|
||||
"test": [
|
||||
"test-decoupled-vault.ts"
|
||||
]
|
||||
},
|
||||
"test:remote-commands": {
|
||||
"test": [
|
||||
"test-remote-commands.ts"
|
||||
]
|
||||
},
|
||||
"test:settings-writeback": {
|
||||
"test": [
|
||||
"test-settings-writeback.ts"
|
||||
]
|
||||
},
|
||||
"test:remote-administration-exit-codes": {
|
||||
"test": [
|
||||
"test-remote-administration-exit-codes.ts"
|
||||
]
|
||||
},
|
||||
"test:push-pull": {
|
||||
"test": [
|
||||
"test-push-pull.ts"
|
||||
]
|
||||
},
|
||||
"test:setup-put-cat": {
|
||||
"test": [
|
||||
"test-setup-put-cat.ts"
|
||||
]
|
||||
},
|
||||
"test:mirror": {
|
||||
"test": [
|
||||
"test-mirror.ts"
|
||||
]
|
||||
},
|
||||
"test:sync-two-local": {
|
||||
"test": [
|
||||
"test-sync-two-local-databases.ts"
|
||||
]
|
||||
},
|
||||
"test:sync-locked-remote": {
|
||||
"test": [
|
||||
"test-sync-locked-remote.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-host": {
|
||||
"test": [
|
||||
"test-p2p-host.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-peers": {
|
||||
"test": [
|
||||
"test-p2p-peers-local-relay.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-sync": {
|
||||
"test": [
|
||||
"test-p2p-sync.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-automatic-host": {
|
||||
"test": [
|
||||
"test-p2p-automatic-host.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-replacement": {
|
||||
"test": [
|
||||
"test-p2p-replicator-replacement.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-relay-disconnect": {
|
||||
"test": [
|
||||
"test-p2p-relay-disconnect.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p:ci": {
|
||||
"test": [
|
||||
"test-p2p-sync.ts",
|
||||
"test-p2p-automatic-host.ts",
|
||||
"test-p2p-replicator-replacement.ts",
|
||||
"test-p2p-relay-disconnect.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-three-nodes": {
|
||||
"test": [
|
||||
"test-p2p-three-nodes-conflict.ts"
|
||||
]
|
||||
},
|
||||
"test:p2p-upload-download": {
|
||||
"test": [
|
||||
"test-p2p-upload-download-repro.ts"
|
||||
]
|
||||
},
|
||||
"test:benchmark-contract": {
|
||||
"test": [
|
||||
"test-benchmark-contract.ts"
|
||||
]
|
||||
},
|
||||
"bench:p2p": {
|
||||
"script": "bench-p2p.ts"
|
||||
},
|
||||
"bench:couchdb": {
|
||||
"script": "bench-couchdb.ts"
|
||||
},
|
||||
"bench:compression": {
|
||||
"script": "bench-compression.ts"
|
||||
},
|
||||
"bench:cases": {
|
||||
"script": "bench-network-cases.ts"
|
||||
},
|
||||
"bench:latency-sweep": {
|
||||
"script": "bench-latency-sweep.ts"
|
||||
},
|
||||
"bench:p2p-split-node": {
|
||||
"script": "bench-p2p-split-node.ts"
|
||||
},
|
||||
"bench:item1": {
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"./bench-run-item1.sh"
|
||||
]
|
||||
},
|
||||
"bench:item1:full": {
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"-lc",
|
||||
"BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh"
|
||||
]
|
||||
},
|
||||
"test:e2e-couchdb": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-couchdb.ts"
|
||||
]
|
||||
},
|
||||
"test:e2e-matrix": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
]
|
||||
},
|
||||
"test:e2e-matrix:couchdb-enc0": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
],
|
||||
"filter": "e2e matrix: COUCHDB-enc0"
|
||||
},
|
||||
"test:e2e-matrix:couchdb-enc1": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
],
|
||||
"filter": "e2e matrix: COUCHDB-enc1"
|
||||
},
|
||||
"test:e2e-matrix:minio-enc0": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
],
|
||||
"filter": "e2e matrix: MINIO-enc0"
|
||||
},
|
||||
"test:e2e-matrix:minio-enc1": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
],
|
||||
"filter": "e2e matrix: MINIO-enc1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts";
|
||||
import { startCouchdbProxy } from "./bench-couchdb.ts";
|
||||
import {
|
||||
parseBenchmarkVerificationMode,
|
||||
selectVerificationEntries,
|
||||
} from "./helpers/benchmarkVerification.ts";
|
||||
import type { DatasetEntry } from "./helpers/dataset.ts";
|
||||
import { createCompressionBenchmarkDataset } from "./helpers/compressionDataset.ts";
|
||||
import { getFreePort } from "./helpers/net.ts";
|
||||
|
||||
function assertStringIncludes(actual: string, expected: string, message?: string): void {
|
||||
assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
|
||||
const found = cases.find((testCase) => testCase.name === name);
|
||||
assert(found, `missing benchmark case: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function parsedLimitations(testCase: BenchmarkCase): string[] {
|
||||
const raw = testCase.env.BENCH_LIMITATIONS_JSON;
|
||||
assert(
|
||||
raw,
|
||||
`${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`,
|
||||
);
|
||||
const parsed = JSON.parse(raw);
|
||||
assert(
|
||||
Array.isArray(parsed),
|
||||
`${testCase.name} limitations must be an array`,
|
||||
);
|
||||
assert(
|
||||
parsed.every((item) =>
|
||||
typeof item === "string" && item.trim().length > 0
|
||||
),
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
test("benchmark cases record scope and limitations for paper use", () => {
|
||||
const cases = buildCases();
|
||||
assert(cases.length > 0);
|
||||
|
||||
for (const testCase of cases) {
|
||||
assert(
|
||||
testCase.description.trim().length > 0,
|
||||
`${testCase.name} must describe the case`,
|
||||
);
|
||||
assert(
|
||||
testCase.dataPath.trim().length > 0,
|
||||
`${testCase.name} must describe the data path`,
|
||||
);
|
||||
assert(
|
||||
testCase.trustBoundary.trim().length > 0,
|
||||
`${testCase.name} must describe the trust boundary`,
|
||||
);
|
||||
assert(
|
||||
testCase.measurementScope.trim().length > 0,
|
||||
`${testCase.name} must describe the measurement scope`,
|
||||
);
|
||||
assert(
|
||||
testCase.limitations.length > 0,
|
||||
`${testCase.name} must list limitations`,
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
testCase.env.BENCH_MEASUREMENT_SCOPE,
|
||||
testCase.measurementScope,
|
||||
);
|
||||
assert.deepStrictEqual(parsedLimitations(testCase), testCase.limitations);
|
||||
assert.deepStrictEqual(
|
||||
testCase.env.BENCH_VERIFY_MODE,
|
||||
"all",
|
||||
`${testCase.name} must verify the complete dataset`,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||
const backend = createServer((_request, response) => response.end("ok"));
|
||||
const backendPort = await new Promise<number>((resolve, reject) => {
|
||||
backend.once("error", reject);
|
||||
backend.listen(0, "127.0.0.1", () => {
|
||||
const address = backend.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Could not read the benchmark backend address"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const delays: number[] = [];
|
||||
const proxy = await startCouchdbProxy({
|
||||
backendUri: `http://127.0.0.1:${backendPort}`,
|
||||
proxyUri: `http://127.0.0.1:${proxyPort}`,
|
||||
requestedRttMs: 20,
|
||||
delay: (milliseconds) => {
|
||||
delays.push(milliseconds);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`);
|
||||
assert.deepStrictEqual(await response.text(), "ok");
|
||||
assert.deepStrictEqual(proxy.directionalDelayMs, 10);
|
||||
assert.deepStrictEqual(delays, [10, 10]);
|
||||
assert.deepStrictEqual(proxy.snapshotCounters(), {
|
||||
requestCount: 1,
|
||||
requestBodyBytes: 0,
|
||||
responseBodyBytes: 2,
|
||||
});
|
||||
proxy.resetCounters();
|
||||
const posted = await fetch(`http://127.0.0.1:${proxyPort}/probe`, {
|
||||
method: "POST",
|
||||
body: "abc",
|
||||
});
|
||||
assert.deepStrictEqual(await posted.text(), "ok");
|
||||
assert.deepStrictEqual(proxy.snapshotCounters(), {
|
||||
requestCount: 1,
|
||||
requestBodyBytes: 3,
|
||||
responseBodyBytes: 2,
|
||||
});
|
||||
} finally {
|
||||
await proxy.stop();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
backend.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
const halfMillisecondProxy = await startCouchdbProxy({
|
||||
backendUri: "http://127.0.0.1:1",
|
||||
proxyUri: `http://127.0.0.1:${await getFreePort()}`,
|
||||
requestedRttMs: 1,
|
||||
delay: () => Promise.resolve(),
|
||||
});
|
||||
try {
|
||||
assert.deepStrictEqual(halfMillisecondProxy.directionalDelayMs, 0.5);
|
||||
} finally {
|
||||
await halfMillisecondProxy.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("compression benchmark dataset covers representative file kinds deterministically", async () => {
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), "livesync-compression-contract-"));
|
||||
const repositoryRoot = `${fixtureRoot}/repository`;
|
||||
const vaultA = `${fixtureRoot}/vault-a`;
|
||||
const vaultB = `${fixtureRoot}/vault-b`;
|
||||
const repositoryFiles = [
|
||||
"docs/settings.md",
|
||||
"docs/quick_setup.md",
|
||||
"updates.md",
|
||||
"instruction_images/cloudant_1.png",
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/serviceFeatures/replication/index.ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
const absolutePath = `${repositoryRoot}/${relativePath}`;
|
||||
await mkdir(
|
||||
absolutePath.slice(0, absolutePath.lastIndexOf("/")),
|
||||
{ recursive: true },
|
||||
);
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
new TextEncoder().encode(
|
||||
`fixture-${index}-${relativePath}\n`.repeat(20),
|
||||
),
|
||||
);
|
||||
}
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
const jpegEncoder = async (_input: string, output: string) => {
|
||||
await writeFile(
|
||||
output,
|
||||
new Uint8Array([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xdb,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0xff,
|
||||
0xd9,
|
||||
]),
|
||||
);
|
||||
return "contract JPEG encoder";
|
||||
};
|
||||
const first = await createCompressionBenchmarkDataset({
|
||||
rootDir: vaultA,
|
||||
repositoryRoot,
|
||||
seed: "contract-seed",
|
||||
jpegEncoder,
|
||||
});
|
||||
const second = await createCompressionBenchmarkDataset({
|
||||
rootDir: vaultB,
|
||||
repositoryRoot,
|
||||
seed: "contract-seed",
|
||||
jpegEncoder,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(first.filesByKind, {
|
||||
md: 3,
|
||||
jpg: 2,
|
||||
png: 2,
|
||||
json: 2,
|
||||
ts: 2,
|
||||
gz: 1,
|
||||
bin: 1,
|
||||
});
|
||||
assert.deepStrictEqual(first.totalFiles, 13);
|
||||
assert.deepStrictEqual(first.jpegGenerator, "contract JPEG encoder");
|
||||
assert.deepStrictEqual(
|
||||
first.entries.map((entry) => [
|
||||
entry.kind,
|
||||
entry.relativePath,
|
||||
entry.size,
|
||||
]),
|
||||
second.entries.map((entry) => [
|
||||
entry.kind,
|
||||
entry.relativePath,
|
||||
entry.size,
|
||||
]),
|
||||
);
|
||||
assert(first.entries.every((entry) => entry.size > 0));
|
||||
} finally {
|
||||
await rm(fixtureRoot, { recursive: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("benchmark verification mode selects either all files or a labelled sample", () => {
|
||||
const entries: DatasetEntry[] = [
|
||||
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
|
||||
{ kind: "md", relativePath: "b.md", absolutePath: "/b", size: 1 },
|
||||
{ kind: "bin", relativePath: "c.bin", absolutePath: "/c", size: 1 },
|
||||
{ kind: "md", relativePath: "d.md", absolutePath: "/d", size: 1 },
|
||||
{ kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 },
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(parseBenchmarkVerificationMode("ALL"), "all");
|
||||
assert.deepStrictEqual(selectVerificationEntries(entries, "all").length, entries.length);
|
||||
const sample = selectVerificationEntries(entries, "sample");
|
||||
assert(sample.length > 0 && sample.length < entries.length);
|
||||
assert(sample.some((entry) => entry.kind === "md"));
|
||||
assert(sample.some((entry) => entry.kind === "bin"));
|
||||
});
|
||||
|
||||
test("P2P signalling-shim cases do not claim to shape the note-data path", () => {
|
||||
const cases = buildCases();
|
||||
for (
|
||||
const name of [
|
||||
"p2p-signalling-netem-home-wifi",
|
||||
"p2p-signalling-netem-tethering-vpn",
|
||||
]
|
||||
) {
|
||||
const testCase = getCase(cases, name);
|
||||
assert.deepStrictEqual(testCase.runner, "p2p");
|
||||
assert.deepStrictEqual(testCase.env.BENCH_TURN_SERVERS, "");
|
||||
assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2");
|
||||
assert.deepStrictEqual(
|
||||
testCase.env.BENCH_NETWORK_MODEL,
|
||||
"compose-netem-signalling-shim",
|
||||
);
|
||||
assertStringIncludes(testCase.dataPath, "WebRTC DataChannel");
|
||||
assertStringIncludes(testCase.dataPath, "Nostr signalling");
|
||||
assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync");
|
||||
assert(
|
||||
testCase.limitations.some((limitation) =>
|
||||
limitation.includes("connection establishment")
|
||||
),
|
||||
`${name} must state that connection establishment is timed`,
|
||||
);
|
||||
assert(
|
||||
testCase.limitations.some((limitation) =>
|
||||
limitation.includes("does not shape the selected WebRTC")
|
||||
),
|
||||
`${name} must avoid claiming that the P2P note-data path was shaped`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => {
|
||||
const cases = buildCases();
|
||||
|
||||
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
|
||||
assert.deepStrictEqual(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
|
||||
assert.deepStrictEqual(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
|
||||
assert(
|
||||
smartphone.limitations.some((limitation) =>
|
||||
limitation.includes("must not be reported as smartphone")
|
||||
),
|
||||
"smartphone/VPN placeholder must not be usable as field evidence by accident",
|
||||
);
|
||||
|
||||
const turn = getCase(cases, "p2p-user-turn");
|
||||
assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:");
|
||||
assert(
|
||||
turn.limitations.some((limitation) =>
|
||||
limitation.includes(
|
||||
"does not prove that the selected ICE path was relayed",
|
||||
)
|
||||
),
|
||||
"TURN case must require selected ICE candidate interpretation",
|
||||
);
|
||||
});
|
||||
|
||||
test("CouchDB netem cases are marked as remote-store baselines", () => {
|
||||
const cases = buildCases();
|
||||
for (
|
||||
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
|
||||
) {
|
||||
const testCase = getCase(cases, name);
|
||||
assert.deepStrictEqual(testCase.runner, "couchdb");
|
||||
assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2");
|
||||
assert.deepStrictEqual(
|
||||
testCase.env.BENCH_NETWORK_MODEL,
|
||||
"compose-netem-tcp-shim",
|
||||
);
|
||||
assertStringIncludes(testCase.measurementScope, "CouchDB");
|
||||
assert(
|
||||
testCase.limitations.some((limitation) =>
|
||||
limitation.includes("not the WebRTC P2P data path")
|
||||
),
|
||||
`${name} must remain scoped to the CouchDB remote-store path`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCliInBackground, type BackgroundCliProcess } from "./helpers/backgroundCli.ts";
|
||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
|
||||
function envOrDefault(keys: string[], fallback: string): string {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function waitForTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
async function waitForText(filePath: string, expected: string, timeoutMs = 45_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let actual = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
actual = await readFile(filePath, "utf8");
|
||||
if (actual === expected) return;
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== "ENOENT") throw error;
|
||||
}
|
||||
await waitForTick();
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for ${filePath} to contain ${JSON.stringify(expected)}; actual=${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMissing(filePath: string, timeoutMs = 45_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
await waitForTick();
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${filePath} to be removed`);
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon: BackgroundCliProcess | undefined): Promise<void> {
|
||||
if (!daemon) return;
|
||||
await daemon.stop().catch(() => {});
|
||||
}
|
||||
|
||||
test("daemon: startup scan uploads, reconciles, and reflects CouchDB files", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-daemon-startup");
|
||||
|
||||
const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, "");
|
||||
const couchdbUser = envOrDefault(["COUCHDB_USER", "username"], "admin");
|
||||
const couchdbPassword = envOrDefault(["COUCHDB_PASSWORD", "password"], "testpassword");
|
||||
const dbPrefix = envOrDefault(["COUCHDB_DBNAME", "dbname"], "livesync-test-db-ci");
|
||||
const dbname = `${dbPrefix}-daemon-startup-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`.toLowerCase();
|
||||
|
||||
const databaseA = workDir.join("database-a");
|
||||
const databaseB = workDir.join("database-b");
|
||||
const databaseC = workDir.join("database-c");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const vaultC = workDir.join("vault-c");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const settingsC = workDir.join("settings-c.json");
|
||||
|
||||
await Promise.all([
|
||||
mkdir(databaseA, { recursive: true }),
|
||||
mkdir(databaseB, { recursive: true }),
|
||||
mkdir(databaseC, { recursive: true }),
|
||||
mkdir(vaultA, { recursive: true }),
|
||||
mkdir(vaultB, { recursive: true }),
|
||||
mkdir(vaultC, { recursive: true }),
|
||||
]);
|
||||
|
||||
const startupPath = "notes/present-before-start.md";
|
||||
const deletePath = "notes/deleted-while-stopped.md";
|
||||
const remoteOnlyPath = "notes/remote-only.md";
|
||||
const startupFileA = join(vaultA, startupPath);
|
||||
const deleteFileA = join(vaultA, deletePath);
|
||||
const startupFileB = join(vaultB, startupPath);
|
||||
const deleteFileB = join(vaultB, deletePath);
|
||||
const remoteOnlyFileB = join(vaultB, remoteOnlyPath);
|
||||
|
||||
await mkdir(join(vaultA, "notes"), { recursive: true });
|
||||
await writeFile(startupFileA, "created before daemon startup\n");
|
||||
const initialTime = new Date(Date.now() - 10_000);
|
||||
await utimes(startupFileA, initialTime, initialTime);
|
||||
await writeFile(deleteFileA, "delete this after the first run\n");
|
||||
|
||||
let daemonA: BackgroundCliProcess | undefined;
|
||||
let daemonB: BackgroundCliProcess | undefined;
|
||||
try {
|
||||
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||
for (const settings of [settingsA, settingsB, settingsC]) {
|
||||
await initSettingsFile(settings);
|
||||
await applyCouchdbSettings(settings, couchdbUri, couchdbUser, couchdbPassword, dbname, true);
|
||||
}
|
||||
|
||||
// A pre-existing local file must be uploaded by the daemon's startup scan.
|
||||
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
|
||||
await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000);
|
||||
|
||||
// A separate daemon proves that the first startup replication reached CouchDB
|
||||
// and that remote files are reflected into its filesystem.
|
||||
daemonB = startCliInBackground(databaseB, "--vault", vaultB, "--settings", settingsB, "daemon");
|
||||
await daemonB.waitUntilContains("[Daemon] Initial replication complete", 45_000);
|
||||
await waitForText(startupFileB, "created before daemon startup\n");
|
||||
await waitForText(deleteFileB, "delete this after the first run\n");
|
||||
|
||||
// Changes made while A is stopped must be found by its next startup scan.
|
||||
assert.deepStrictEqual(await daemonA.stop(), 0, daemonA.combined);
|
||||
daemonA = undefined;
|
||||
await writeFile(startupFileA, "edited while daemon was stopped\n");
|
||||
await unlink(deleteFileA);
|
||||
|
||||
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
|
||||
await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000);
|
||||
await waitForText(startupFileB, "edited while daemon was stopped\n");
|
||||
await waitForMissing(deleteFileB);
|
||||
|
||||
// Seed a file into a third local database without creating it in vault C.
|
||||
// After C's finite sync, it exists only remotely from B's point of view.
|
||||
await runCliWithInputOrFail(
|
||||
"created in a different local database\n",
|
||||
databaseC,
|
||||
"--vault",
|
||||
vaultC,
|
||||
"--settings",
|
||||
settingsC,
|
||||
"put",
|
||||
remoteOnlyPath
|
||||
);
|
||||
await runCliOrFail(databaseC, "--vault", vaultC, "--settings", settingsC, "sync");
|
||||
await waitForText(remoteOnlyFileB, "created in a different local database\n");
|
||||
assert.deepStrictEqual(await readFile(startupFileB, "utf8"), "edited while daemon was stopped\n");
|
||||
assert.deepStrictEqual(((await stat(remoteOnlyFileB))).isFile(), true);
|
||||
} finally {
|
||||
await stopDaemon(daemonB);
|
||||
await stopDaemon(daemonA);
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Node.js port of test-daemon-linux.sh
|
||||
*
|
||||
* Tests daemon-related ignore rules behaviour.
|
||||
*
|
||||
* Tests that are runnable without a long-running daemon process are exercised
|
||||
* here using the 'mirror' command, which calls the same 'isTargetFile' handler
|
||||
* stack that the daemon uses.
|
||||
*
|
||||
* Covered cases:
|
||||
* 1. .livesync/ignore with *.tmp pattern → ignored file is not synced to database
|
||||
* 2. .livesync/ignore missing → no error, and normal synchronisation continues
|
||||
* 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||
*
|
||||
* Run:
|
||||
* node --test test-daemon.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts";
|
||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||
|
||||
test("daemon: ignore rules behaviour", async (t) => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Case 1: .livesync/ignore with *.tmp → ignored file not synced to database
|
||||
// -------------------------------------------------------------------------
|
||||
await t.test("case 1: .livesync/ignore *.tmp prevents sync", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-daemon-c1");
|
||||
const settingsFile = workDir.join("data.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
|
||||
await mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
||||
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsFile);
|
||||
await markSettingsConfigured(settingsFile);
|
||||
|
||||
await writeFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n");
|
||||
await writeFile(join(vaultDir, "notes", "normal.md"), "normal content\n");
|
||||
await writeFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n");
|
||||
|
||||
console.log("[INFO] Running mirror for Case 1...");
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
||||
|
||||
// The normal file should be in the database.
|
||||
const resultNormal = workDir.join("case1-normal.txt");
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/normal.md", resultNormal);
|
||||
const normalContent = await readFile(resultNormal, "utf8");
|
||||
assert.deepStrictEqual(normalContent, "normal content\n", "normal.md content mismatch after mirror");
|
||||
|
||||
// The .tmp file should NOT be in the database.
|
||||
const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls");
|
||||
assertNotContains(dbList, "scratch.tmp", "scratch.tmp (ignored) was unexpectedly synced to database");
|
||||
assertContains(dbList, "normal.md", "normal.md was not found in database after mirror");
|
||||
console.log("[PASS] Case 1 verified successfully");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Case 2: .livesync/ignore absent → no error, and normal synchronisation continues
|
||||
// -------------------------------------------------------------------------
|
||||
await t.test("case 2: .livesync/ignore absent does not cause failure", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-daemon-c2");
|
||||
const settingsFile = workDir.join("data2.json");
|
||||
const vaultDir = workDir.join("vault2");
|
||||
|
||||
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsFile);
|
||||
await markSettingsConfigured(settingsFile);
|
||||
|
||||
// No .livesync directory at all.
|
||||
await writeFile(join(vaultDir, "notes", "hello.md"), "hello\n");
|
||||
|
||||
console.log("[INFO] Running mirror for Case 2...");
|
||||
const result = await runCli(vaultDir, "--settings", settingsFile, "mirror");
|
||||
assert.deepStrictEqual(result.code, 0, "mirror exited non-zero when .livesync/ignore is absent");
|
||||
|
||||
// The normal file should have been synced.
|
||||
const resultHello = workDir.join("case2-hello.txt");
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/hello.md", resultHello);
|
||||
const helloContent = await readFile(resultHello, "utf8");
|
||||
assert.deepStrictEqual(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent");
|
||||
console.log("[PASS] Case 2 verified successfully");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Case 3: import: .gitignore merges patterns
|
||||
// -------------------------------------------------------------------------
|
||||
await t.test("case 3: import: .gitignore directive merges patterns", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-daemon-c3");
|
||||
const settingsFile = workDir.join("data3.json");
|
||||
const vaultDir = workDir.join("vault3");
|
||||
|
||||
await mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
||||
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsFile);
|
||||
await markSettingsConfigured(settingsFile);
|
||||
|
||||
await writeFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n");
|
||||
await writeFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n");
|
||||
|
||||
await writeFile(join(vaultDir, "notes", "regular.md"), "regular note\n");
|
||||
await writeFile(join(vaultDir, "notes", "debug.log"), "log content\n");
|
||||
|
||||
console.log("[INFO] Running mirror for Case 3...");
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
||||
|
||||
const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls");
|
||||
assertNotContains(
|
||||
dbList,
|
||||
"debug.log",
|
||||
"debug.log (ignored via .gitignore import) was unexpectedly synced to database"
|
||||
);
|
||||
assertContains(dbList, "regular.md", "regular.md was not synced normally alongside .gitignore import rules");
|
||||
console.log("[PASS] Case 3 verified successfully");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Node.js port of test-decoupled-vault-linux.sh
|
||||
*
|
||||
* Tests push, pull, and mirror command behaviour when the vault directory is
|
||||
* decoupled (separated) from the database directory.
|
||||
*
|
||||
* Run:
|
||||
* node --test test-decoupled-vault.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
|
||||
const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/push-pull-decoupled.txt";
|
||||
|
||||
test("decoupled database and vault", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-decoupled");
|
||||
|
||||
const settingsFile = workDir.join("data.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
const dbDir = workDir.join("db");
|
||||
|
||||
await mkdir(join(vaultDir, "test"), { recursive: true });
|
||||
await mkdir(dbDir, { recursive: true });
|
||||
|
||||
const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||
const dbname = process.env["COUCHDB_DBNAME"] ?? `decoupled-${Date.now()}`;
|
||||
|
||||
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
|
||||
if (shouldStartDocker) {
|
||||
await startCouchdb(uri, user, password, dbname);
|
||||
}
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsFile);
|
||||
|
||||
if (uri && user && password && dbname) {
|
||||
console.log("[INFO] applying CouchDB environment variables to settings");
|
||||
await applyCouchdbSettings(settingsFile, uri, user, password, dbname);
|
||||
} else {
|
||||
console.warn("[WARN] CouchDB environment variables are not fully set. Push and pull operations may fail.");
|
||||
await markSettingsConfigured(settingsFile);
|
||||
}
|
||||
|
||||
const srcFile = workDir.join("push-source.txt");
|
||||
const pulledFile = workDir.join("pull-result.txt");
|
||||
const content = `push-pull-decoupled-test ${new Date().toISOString()}\n`;
|
||||
await writeFile(srcFile, content);
|
||||
|
||||
// 1. Test push command with decoupled vault directory
|
||||
console.log(`[INFO] push with decoupled vault -> ${REMOTE_PATH}`);
|
||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "push", srcFile, REMOTE_PATH);
|
||||
|
||||
// 2. Test pull command with decoupled vault directory
|
||||
console.log(`[INFO] pull with decoupled vault <- ${REMOTE_PATH}`);
|
||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
||||
|
||||
const pulled = await readFile(pulledFile, "utf8");
|
||||
assert.deepStrictEqual(pulled, content, "push/pull roundtrip with decoupled vault content mismatch");
|
||||
console.log("[PASS] push/pull roundtrip with decoupled vault matched");
|
||||
|
||||
// 3. Clean up pulled file and vault test directory to verify mirror
|
||||
await unlink(pulledFile).catch(() => {});
|
||||
await rm(join(vaultDir, "test"), { recursive: true }).catch(() => {});
|
||||
|
||||
// 4. Test mirror command with decoupled vault directory
|
||||
console.log("[INFO] mirror with decoupled vault");
|
||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "mirror");
|
||||
|
||||
const restoredFile = join(vaultDir, REMOTE_PATH);
|
||||
const restored = await readFile(restoredFile, "utf8");
|
||||
assert.deepStrictEqual(restored, content, "mirror with decoupled vault content mismatch");
|
||||
console.log("[PASS] mirror with decoupled vault matched");
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import {
|
||||
runCli,
|
||||
runCliOrFail,
|
||||
runCliWithInputOrFail,
|
||||
sanitiseCatStdout,
|
||||
assertFilesEqual,
|
||||
jsonStringField,
|
||||
} from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
||||
type RemoteType = "COUCHDB" | "MINIO";
|
||||
|
||||
function requireEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
const couchdbUri = remoteType === "COUCHDB" ? requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "") : "";
|
||||
const couchdbUser = remoteType === "COUCHDB" ? requireEnv("COUCHDB_USER", "username") : "";
|
||||
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv("COUCHDB_PASSWORD", "password") : "";
|
||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
||||
|
||||
const minioEndpoint =
|
||||
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
||||
|
||||
const passphrase = "e2e-passphrase";
|
||||
|
||||
await using workDir = await TempDir.create(
|
||||
`livesync-cli-e2e-${remoteType.toLowerCase()}-${encrypt ? "enc1" : "enc0"}`
|
||||
);
|
||||
const vaultA = workDir.join("testvault_a");
|
||||
const vaultB = workDir.join("testvault_b");
|
||||
const settingsA = workDir.join("test-settings-a.json");
|
||||
const settingsB = workDir.join("test-settings-b.json");
|
||||
const pushSrc = workDir.join("push-source.txt");
|
||||
const pullDst = workDir.join("pull-destination.txt");
|
||||
const pushBinarySrc = workDir.join("push-source.bin");
|
||||
const pullBinaryDst = workDir.join("pull-destination.bin");
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
if (remoteType === "COUCHDB") {
|
||||
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||
} else {
|
||||
await startMinio(minioEndpoint, minioAccessKey, minioSecretKey, minioBucket);
|
||||
}
|
||||
|
||||
try {
|
||||
if (remoteType === "MINIO") {
|
||||
// The shared S3 fixture also serves the browser and real Obsidian tests.
|
||||
const origin = "app://obsidian.md";
|
||||
const requestedHeaders = ["authorization", "content-type", "x-amz-date", "x-amz-content-sha256"];
|
||||
const preflight = await fetch(`${minioEndpoint}/${minioBucket}`, {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
Origin: origin,
|
||||
"Access-Control-Request-Method": "PUT",
|
||||
"Access-Control-Request-Headers": requestedHeaders.join(","),
|
||||
},
|
||||
});
|
||||
await preflight.body?.cancel();
|
||||
assert(preflight.ok, "The S3 fixture must accept browser preflight requests");
|
||||
const allowedOrigin = preflight.headers.get("access-control-allow-origin");
|
||||
assert(allowedOrigin === "*" || allowedOrigin === origin, "The S3 fixture must allow the Obsidian origin");
|
||||
const allowedHeaders = (preflight.headers.get("access-control-allow-headers") ?? "")
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((header) => header.trim());
|
||||
assert(allowedHeaders.includes("authorization"), "S3 CORS must explicitly allow the Authorization header");
|
||||
assert(
|
||||
preflight.headers.get("access-control-allow-methods")?.split(/,\s*/).includes("PUT"),
|
||||
"S3 CORS must allow browser uploads"
|
||||
);
|
||||
}
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
await applyRemoteSyncSettings(settingsA, {
|
||||
remoteType,
|
||||
couchdbUri,
|
||||
couchdbUser,
|
||||
couchdbPassword,
|
||||
couchdbDbname: dbname,
|
||||
minioBucket,
|
||||
minioEndpoint,
|
||||
minioAccessKey,
|
||||
minioSecretKey,
|
||||
encrypt,
|
||||
passphrase,
|
||||
});
|
||||
await applyRemoteSyncSettings(settingsB, {
|
||||
remoteType,
|
||||
couchdbUri,
|
||||
couchdbUser,
|
||||
couchdbPassword,
|
||||
couchdbDbname: dbname,
|
||||
minioBucket,
|
||||
minioEndpoint,
|
||||
minioAccessKey,
|
||||
minioSecretKey,
|
||||
encrypt,
|
||||
passphrase,
|
||||
});
|
||||
|
||||
const syncBoth = async () => {
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
};
|
||||
|
||||
const targetAOnly = "e2e/a-only-info.md";
|
||||
const targetSync = "e2e/sync-info.md";
|
||||
const targetSyncTwiceFirst = "e2e/sync-twice-first.md";
|
||||
const targetSyncTwiceSecond = "e2e/sync-twice-second.md";
|
||||
const targetPush = "e2e/pushed-from-a.md";
|
||||
const targetPut = "e2e/put-from-a.md";
|
||||
const targetPushBinary = "e2e/pushed-from-a.bin";
|
||||
const targetConflict = "e2e/conflict.md";
|
||||
|
||||
await runCliWithInputOrFail("alpha-from-a\n", vaultA, "--settings", settingsA, "put", targetAOnly);
|
||||
const infoAOnly = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetAOnly);
|
||||
assert(infoAOnly.includes(`"path": "${targetAOnly}"`));
|
||||
|
||||
await runCliWithInputOrFail("visible-after-sync\n", vaultA, "--settings", settingsA, "put", targetSync);
|
||||
await syncBoth();
|
||||
const infoBSync = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetSync);
|
||||
assert(infoBSync.includes(`"path": "${targetSync}"`));
|
||||
|
||||
await runCliWithInputOrFail(
|
||||
`first-sync-round-${dbSuffix}\n`,
|
||||
vaultA,
|
||||
"--settings",
|
||||
settingsA,
|
||||
"put",
|
||||
targetSyncTwiceFirst
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const firstVisible = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetSyncTwiceFirst)
|
||||
).trimEnd();
|
||||
assert(firstVisible === `first-sync-round-${dbSuffix}`);
|
||||
|
||||
await runCliWithInputOrFail(
|
||||
`second-sync-round-${dbSuffix}\n`,
|
||||
vaultA,
|
||||
"--settings",
|
||||
settingsA,
|
||||
"put",
|
||||
targetSyncTwiceSecond
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const secondVisible = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetSyncTwiceSecond)
|
||||
).trimEnd();
|
||||
assert(secondVisible === `second-sync-round-${dbSuffix}`);
|
||||
|
||||
await writeFile(pushSrc, `pushed-content-${dbSuffix}\n`);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushSrc, targetPush);
|
||||
await runCliWithInputOrFail(`put-content-${dbSuffix}\n`, vaultA, "--settings", settingsA, "put", targetPut);
|
||||
await syncBoth();
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPush, pullDst);
|
||||
await assertFilesEqual(pushSrc, pullDst, "B pull result does not match pushed source");
|
||||
const catBPut = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetPut)
|
||||
).trimEnd();
|
||||
assert(catBPut === `put-content-${dbSuffix}`);
|
||||
|
||||
const binary = new Uint8Array(4096);
|
||||
binary.fill(0x61);
|
||||
await writeFile(pushBinarySrc, binary);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushBinarySrc, targetPushBinary);
|
||||
await syncBoth();
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPushBinary, pullBinaryDst);
|
||||
await assertFilesEqual(pushBinarySrc, pullBinaryDst, "B pull result does not match pushed binary source");
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "rm", targetPut);
|
||||
await syncBoth();
|
||||
const removed = await runCli(vaultB, "--settings", settingsB, "cat", targetPut);
|
||||
assert(removed.code !== 0, `B cat should fail after A removed the file\n${removed.combined}`);
|
||||
|
||||
await runCliWithInputOrFail("conflict-base\n", vaultA, "--settings", settingsA, "put", targetConflict);
|
||||
await syncBoth();
|
||||
await runCliWithInputOrFail(
|
||||
`conflict-from-a-${dbSuffix}\n`,
|
||||
vaultA,
|
||||
"--settings",
|
||||
settingsA,
|
||||
"put",
|
||||
targetConflict
|
||||
);
|
||||
await runCliWithInputOrFail(
|
||||
`conflict-from-b-${dbSuffix}\n`,
|
||||
vaultB,
|
||||
"--settings",
|
||||
settingsB,
|
||||
"put",
|
||||
targetConflict
|
||||
);
|
||||
|
||||
let infoAConflict = "";
|
||||
let infoBConflict = "";
|
||||
let conflictDetected = false;
|
||||
for (const side of ["a", "b", "a"] as const) {
|
||||
await runCliOrFail(
|
||||
side === "a" ? vaultA : vaultB,
|
||||
"--settings",
|
||||
side === "a" ? settingsA : settingsB,
|
||||
"sync"
|
||||
);
|
||||
infoAConflict = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetConflict);
|
||||
infoBConflict = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetConflict);
|
||||
if (
|
||||
jsonStringField(infoAConflict, "conflicts") !== "N/A" ||
|
||||
jsonStringField(infoBConflict, "conflicts") !== "N/A"
|
||||
) {
|
||||
conflictDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(conflictDetected, `conflict was expected\nA: ${infoAConflict}\nB: ${infoBConflict}`);
|
||||
|
||||
const lsAConflict =
|
||||
(await runCliOrFail(vaultA, "--settings", settingsA, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
||||
const lsBConflict =
|
||||
(await runCliOrFail(vaultB, "--settings", settingsB, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
||||
const revA = lsAConflict.split("\t")[3] ?? "";
|
||||
const revB = lsBConflict.split("\t")[3] ?? "";
|
||||
assert(
|
||||
revA.includes("*") || revB.includes("*"),
|
||||
`conflicted entry should be marked with '*'\nA: ${lsAConflict}\nB: ${lsBConflict}`
|
||||
);
|
||||
|
||||
const keepRevision = jsonStringField(infoAConflict, "revision");
|
||||
assert(keepRevision.length > 0, `could not extract revision\n${infoAConflict}`);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "resolve", targetConflict, keepRevision);
|
||||
|
||||
let resolved = false;
|
||||
let infoAResolved = "";
|
||||
let infoBResolved = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await syncBoth();
|
||||
infoAResolved = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetConflict);
|
||||
infoBResolved = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetConflict);
|
||||
if (
|
||||
jsonStringField(infoAResolved, "conflicts") === "N/A" &&
|
||||
jsonStringField(infoBResolved, "conflicts") === "N/A"
|
||||
) {
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
const retryRevision = jsonStringField(infoAResolved, "revision");
|
||||
if (retryRevision) {
|
||||
await runCli(vaultA, "--settings", settingsA, "resolve", targetConflict, retryRevision);
|
||||
}
|
||||
}
|
||||
assert(resolved, `conflicts should be resolved\nA: ${infoAResolved}\nB: ${infoBResolved}`);
|
||||
|
||||
const lsAResolved =
|
||||
(await runCliOrFail(vaultA, "--settings", settingsA, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
||||
const lsBResolved =
|
||||
(await runCliOrFail(vaultB, "--settings", settingsB, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
||||
assert(!(lsAResolved.split("\t")[3] ?? "").includes("*"));
|
||||
assert(!(lsBResolved.split("\t")[3] ?? "").includes("*"));
|
||||
|
||||
const catAResolved = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "cat", targetConflict)
|
||||
).trimEnd();
|
||||
const catBResolved = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetConflict)
|
||||
).trimEnd();
|
||||
assert(catAResolved === catBResolved, `resolved content should match\nA: ${catAResolved}\nB: ${catBResolved}`);
|
||||
} finally {
|
||||
if (!keepDocker) {
|
||||
if (remoteType === "COUCHDB") {
|
||||
await stopCouchdb().catch(() => {});
|
||||
} else {
|
||||
await stopMinio().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("e2e: two vaults over CouchDB without encryption", async () => {
|
||||
await runScenario("COUCHDB", false);
|
||||
});
|
||||
|
||||
test("e2e: two vaults over CouchDB with encryption", async () => {
|
||||
await runScenario("COUCHDB", true);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test } from "node:test";
|
||||
import { runScenario } from "./test-e2e-two-vaults-couchdb.ts";
|
||||
|
||||
type MatrixCase = {
|
||||
remoteType: "COUCHDB" | "MINIO";
|
||||
encrypt: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const matrixCases: MatrixCase[] = [
|
||||
{ remoteType: "COUCHDB", encrypt: false, label: "COUCHDB-enc0" },
|
||||
{ remoteType: "COUCHDB", encrypt: true, label: "COUCHDB-enc1" },
|
||||
{ remoteType: "MINIO", encrypt: false, label: "MINIO-enc0" },
|
||||
{ remoteType: "MINIO", encrypt: true, label: "MINIO-enc1" },
|
||||
];
|
||||
|
||||
for (const tc of matrixCases) {
|
||||
test(`e2e matrix: ${tc.label}`, async () => {
|
||||
await runScenario(tc.remoteType, tc.encrypt);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Node.js port of test-mirror-linux.sh
|
||||
*
|
||||
* Tests the `mirror` command — bidirectional synchronisation between a local
|
||||
* storage directory (vault) and an in-process database.
|
||||
*
|
||||
* Covered cases (identical to the bash test):
|
||||
* 1. Storage-only file -> synced into DB (UPDATE DATABASE)
|
||||
* 2. DB-only file -> restored to storage (UPDATE STORAGE)
|
||||
* 3. DB-deleted file -> NOT restored to storage (UPDATE STORAGE skip)
|
||||
* 4. Both, storage newer -> DB updated (SYNC: STORAGE -> DB)
|
||||
* 5. Both, DB newer -> storage updated (SYNC: DB -> STORAGE)
|
||||
* 6. Compatibility mode -> omitted vault-path works (same DB + vault path)
|
||||
* 7. Unknown local origin -> conflict preserved, deduplicated, and resolved
|
||||
*
|
||||
* No external services are required.
|
||||
*
|
||||
* Run:
|
||||
* node --test test-mirror.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { copyFile, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
|
||||
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||
|
||||
test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||
await using workDir = await TempDir.create("livesync-cli-mirror");
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Shared setup
|
||||
// -------------------------------------------------------------------
|
||||
const settingsFile = workDir.join("data.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
const dbDir = workDir.join("db");
|
||||
await mkdir(workDir.join("vault", "test"), { recursive: true });
|
||||
await mkdir(dbDir, { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsFile);
|
||||
// isConfigured=true is required for canProceedScan in the mirror command.
|
||||
await markSettingsConfigured(settingsFile);
|
||||
|
||||
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
data.writeDocumentsIfConflicted = true;
|
||||
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
|
||||
// Copy settings to the DB directory (separated-path mode)
|
||||
const dbSettings = workDir.join("db", "settings.json");
|
||||
await copyFile(settingsFile, dbSettings);
|
||||
|
||||
/** Run mirror in separated-path mode: DB dir ≠ vault dir. */
|
||||
const runMirror = () => runCliOrFail(dbDir, "--settings", dbSettings, "mirror", vaultDir);
|
||||
|
||||
/** Run mirror in compatibility mode: DB path = vault path. */
|
||||
const runMirrorCompat = () => runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
||||
|
||||
// Helper wrappers
|
||||
const dbRun = (...args: string[]) => runCliOrFail(dbDir, "--settings", dbSettings, ...args);
|
||||
const compatRun = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 1: storage-only -> DB (UPDATE DATABASE)
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 1: storage-only file is synced into DB", async () => {
|
||||
const storageFile = workDir.join("vault", "test", "storage-only.md");
|
||||
await writeFile(storageFile, "storage-only content\n");
|
||||
|
||||
await runMirror();
|
||||
|
||||
const resultFile = workDir.join("case1-pull.txt");
|
||||
await dbRun("pull", "test/storage-only.md", resultFile);
|
||||
|
||||
const storageContent = await readFile(storageFile, "utf8");
|
||||
const pulledContent = await readFile(resultFile, "utf8");
|
||||
assert(
|
||||
storageContent === pulledContent,
|
||||
`storage-only file NOT synced into DB\nexpected: ${storageContent}\ngot: ${pulledContent}`
|
||||
);
|
||||
console.log("[PASS] case 1: storage-only file was synced into DB");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 2: DB-only -> storage (UPDATE STORAGE)
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 2: DB-only file is restored to storage", async () => {
|
||||
await dbRun(
|
||||
"push",
|
||||
// write inline via push (pipe not needed — push takes a file path)
|
||||
// create a temp file with content and push it
|
||||
await (async () => {
|
||||
const tmp = workDir.join("db-only-src.txt");
|
||||
await writeFile(tmp, "db-only content\n");
|
||||
return tmp;
|
||||
})(),
|
||||
"test/db-only.md"
|
||||
);
|
||||
|
||||
const storagePath = workDir.join("vault", "test", "db-only.md");
|
||||
assert(!(await exists(storagePath)), "db-only.md unexpectedly exists in storage before mirror");
|
||||
|
||||
await runMirror();
|
||||
|
||||
assert(await exists(storagePath), "DB-only file NOT restored to storage after mirror");
|
||||
const content = await readFile(storagePath, "utf8");
|
||||
assert(content === "db-only content\n", `DB-only file restored but content mismatch: '${content}'`);
|
||||
console.log("[PASS] case 2: DB-only file was restored to storage");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 3: DB-deleted -> storage untouched
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 3: DB-deleted entry is NOT restored to storage", async () => {
|
||||
const deletedSrc = workDir.join("deleted-src.txt");
|
||||
await writeFile(deletedSrc, "to-be-deleted\n");
|
||||
await dbRun("push", deletedSrc, "test/deleted.md");
|
||||
await dbRun("rm", "test/deleted.md");
|
||||
|
||||
await runMirror();
|
||||
|
||||
const storagePath = workDir.join("vault", "test", "deleted.md");
|
||||
assert(!(await exists(storagePath)), "deleted DB entry was incorrectly restored to storage");
|
||||
console.log("[PASS] case 3: deleted DB entry was NOT restored to storage");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 4: storage newer -> DB updated (SYNC: STORAGE -> DB)
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 4: storage newer than DB -> DB is updated", async () => {
|
||||
// Seed DB with old content (mtime ~ now)
|
||||
const seedFile = workDir.join("case4-seed.txt");
|
||||
await writeFile(seedFile, "old content\n");
|
||||
await dbRun("push", seedFile, "test/sync-storage-newer.md");
|
||||
|
||||
// Reflect the shared base into the actual Vault before editing it.
|
||||
await runMirror();
|
||||
assert.deepStrictEqual(await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), "old content\n");
|
||||
|
||||
// Write new content to storage with a timestamp 1 hour in the future
|
||||
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
||||
await writeFile(storageFile, "new content\n");
|
||||
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
||||
|
||||
await runMirror();
|
||||
|
||||
const resultFile = workDir.join("case4-pull.txt");
|
||||
const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md"));
|
||||
assert.deepStrictEqual(info.conflicts, "N/A", "An ordinary local edit must not create a conflict");
|
||||
await dbRun("pull", "test/sync-storage-newer.md", resultFile);
|
||||
const storageContent = await readFile(storageFile, "utf8");
|
||||
const pulledContent = await readFile(resultFile, "utf8");
|
||||
assert(
|
||||
storageContent === pulledContent,
|
||||
`DB NOT updated to match newer storage file\nexpected: ${storageContent}\ngot: ${pulledContent}`
|
||||
);
|
||||
console.log("[PASS] case 4: DB updated to match newer storage file");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 5: DB newer -> storage updated (SYNC: DB -> STORAGE)
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 5: DB newer than storage -> storage is updated", async () => {
|
||||
// Write old content to storage with a timestamp 1 hour in the past
|
||||
const storageFile = workDir.join("vault", "test", "sync-db-newer.md");
|
||||
await writeFile(storageFile, "old storage content\n");
|
||||
await utimes(storageFile, new Date(), new Date(Date.now() - 3600_000));
|
||||
|
||||
// Write new content to DB only (mtime ~ now, newer than the storage file)
|
||||
const dbNewFile = workDir.join("case5-db-new.txt");
|
||||
await writeFile(dbNewFile, "new db content\n");
|
||||
await dbRun("push", dbNewFile, "test/sync-db-newer.md");
|
||||
|
||||
await runMirror();
|
||||
|
||||
const content = await readFile(storageFile, "utf8");
|
||||
assert(content === "new db content\n", `storage NOT updated to match newer DB entry (got: '${content}')`);
|
||||
console.log("[PASS] case 5: storage updated to match newer DB entry");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 6: compatibility mode (vault path = DB path)
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 6: compatibility mode (omitted vault-path)", async () => {
|
||||
const compatFile = workDir.join("vault", "compat.md");
|
||||
await writeFile(compatFile, "compat-content\n");
|
||||
|
||||
await runMirrorCompat();
|
||||
|
||||
const resultFile = workDir.join("case6-pull.txt");
|
||||
await compatRun("pull", "compat.md", resultFile);
|
||||
const pulled = await readFile(resultFile, "utf8");
|
||||
assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`);
|
||||
console.log("[PASS] case 6: compatibility mode works");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Case 7: unknown local origin must preserve both contents regardless of mtime.
|
||||
// This deliberately uses put: push would record a file provenance entry.
|
||||
// -------------------------------------------------------------------
|
||||
await t.test("case 7: unknown local content is preserved, deduplicated, and resolved", async () => {
|
||||
const path = "test/unknown-origin.md";
|
||||
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
|
||||
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
|
||||
const writeUnknownFile = async () => {
|
||||
await writeFile(storageFile, "unrelated local content\n");
|
||||
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
||||
};
|
||||
await writeUnknownFile();
|
||||
await runMirror();
|
||||
|
||||
const info = JSON.parse(await dbRun("info", path));
|
||||
assert(/^1-[\da-f]+$/.test(info.revision), "Expected an independent winning root");
|
||||
assert(/^1-[\da-f]+$/.test(info.conflicts), "Expected exactly one independent conflicting root");
|
||||
assert(info.revision !== info.conflicts, "Expected two distinct revisions");
|
||||
const contents = new Map<string, string>();
|
||||
for (const revision of [info.revision, info.conflicts]) {
|
||||
contents.set(await dbRun("cat-rev", path, revision), revision);
|
||||
}
|
||||
assert.deepStrictEqual([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]);
|
||||
|
||||
// Re-submit identical local bytes even if incoming reflection replaced
|
||||
// the file under writeDocumentsIfConflicted; no third branch is needed.
|
||||
await writeUnknownFile();
|
||||
await runMirror();
|
||||
const repeated = JSON.parse(await dbRun("info", path));
|
||||
assert.deepStrictEqual(repeated.revision, info.revision);
|
||||
assert.deepStrictEqual(repeated.conflicts, info.conflicts);
|
||||
|
||||
const localRevision = contents.get("unrelated local content\n")!;
|
||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
|
||||
const resolved = JSON.parse(await dbRun("info", path));
|
||||
assert.deepStrictEqual(resolved.conflicts, "N/A");
|
||||
assert.deepStrictEqual(resolved.revision, localRevision);
|
||||
assert.deepStrictEqual(await readFile(storageFile, "utf8"), "unrelated local content\n");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { test } from "node:test";
|
||||
import process from "node:process";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { spawnProcess, type ProcessStatus, type RunningProcess } from "./helpers/process.ts";
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { CLI_DIR } from "./helpers/cli.ts";
|
||||
import { maybeStartCoturn, maybeStartLocalRelay, stopCoturnIfStarted, stopLocalRelayIfStarted } from "./helpers/p2p.ts";
|
||||
|
||||
const FRAME_PREFIX = "@@LIVESYNC_P2P_AUTOMATIC@@";
|
||||
const SEED_PATH_ENV = "LIVESYNC_CLI_P2P_AUTOMATIC_SEED_PATH";
|
||||
const SEED_CONTENT_ENV = "LIVESYNC_CLI_P2P_AUTOMATIC_SEED_CONTENT";
|
||||
const HOST_BASELINE = {
|
||||
path: "p2p/automatic-host-baseline.md",
|
||||
content: "Saved by the persistent P2P host.",
|
||||
};
|
||||
const CLIENT_A_BASELINE = {
|
||||
path: "p2p/automatic-client-a-baseline.md",
|
||||
content: "Saved by client A before joining.",
|
||||
};
|
||||
const CLIENT_A_UPDATE = {
|
||||
path: "p2p/automatic-client-a-update.md",
|
||||
content: "Sent through the host auto-watch.",
|
||||
};
|
||||
const HOST_UPDATE = {
|
||||
path: "p2p/automatic-host-update.md",
|
||||
content: "Broadcast by the host to its watcher.",
|
||||
};
|
||||
const CLIENT_B_UPDATE = {
|
||||
path: "p2p/automatic-client-b-update.md",
|
||||
content: "Sent through the restarted host's watcher.",
|
||||
};
|
||||
const CLIENT_B_DURING_OUTAGE = {
|
||||
path: "p2p/automatic-client-b-during-outage.md",
|
||||
content: "Retained by client B while the host restarts.",
|
||||
};
|
||||
|
||||
type FixtureState = {
|
||||
connected: boolean;
|
||||
peers: { name: string; peerId: string }[];
|
||||
policy: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FixtureResponse = {
|
||||
type: "response";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type FixtureReady = {
|
||||
type: "ready";
|
||||
connected: boolean;
|
||||
policy: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, description: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Timed out ${description} after ${timeoutMs}ms`)), timeoutMs);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
class AutomaticP2PHostProcess {
|
||||
#stdout = "";
|
||||
#stdoutLineBuffer = "";
|
||||
#stderr = "";
|
||||
#nextId = 0;
|
||||
#pending = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: FixtureResponse) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
>();
|
||||
#resolveReady!: (value: FixtureReady) => void;
|
||||
#rejectReady!: (reason: Error) => void;
|
||||
#ready = new Promise<FixtureReady>((resolve, reject) => {
|
||||
this.#resolveReady = resolve;
|
||||
this.#rejectReady = reject;
|
||||
});
|
||||
#stdinWriter: WritableStreamDefaultWriter<Uint8Array>;
|
||||
#stdoutTask: Promise<void>;
|
||||
#stderrTask: Promise<void>;
|
||||
#status: Promise<ProcessStatus>;
|
||||
#stopped = false;
|
||||
|
||||
readonly child: RunningProcess;
|
||||
|
||||
private constructor(child: RunningProcess) {
|
||||
this.child = child;
|
||||
this.#stdinWriter = child.stdin.getWriter();
|
||||
this.#stdoutTask = this.#pump(child.stdout, (text) => this.#handleStdout(text));
|
||||
this.#stderrTask = this.#pump(child.stderr, (text) => (this.#stderr += text));
|
||||
this.#status = child.status.then((status) => {
|
||||
if (!this.#stopped) {
|
||||
const error = new Error(
|
||||
`P2P automatic-host fixture exited with code ${status.code}\n${this.diagnostics}`
|
||||
);
|
||||
this.#rejectReady(error);
|
||||
for (const pending of this.#pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.#pending.clear();
|
||||
}
|
||||
return status;
|
||||
});
|
||||
}
|
||||
|
||||
static async start(
|
||||
vaultPath: string,
|
||||
settingsPath: string,
|
||||
seed: { path: string; content: string } | undefined,
|
||||
readyTimeoutMs: number
|
||||
): Promise<AutomaticP2PHostProcess> {
|
||||
const env: Record<string, string> = {};
|
||||
if (seed) {
|
||||
env[SEED_PATH_ENV] = seed.path;
|
||||
env[SEED_CONTENT_ENV] = seed.content;
|
||||
}
|
||||
const child = spawnProcess(process.execPath, {
|
||||
args: [join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), vaultPath, "--settings", settingsPath, "p2p-host"],
|
||||
cwd: CLI_DIR,
|
||||
env,
|
||||
stdin: "piped",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
const fixture = new AutomaticP2PHostProcess(child);
|
||||
try {
|
||||
await withTimeout(fixture.#ready, readyTimeoutMs, "waiting for the P2P automatic-host fixture to connect");
|
||||
return fixture;
|
||||
} catch (error) {
|
||||
await fixture.#kill().catch(() => {});
|
||||
throw new Error(`${String(error)}\n${fixture.diagnostics}`);
|
||||
}
|
||||
}
|
||||
|
||||
get diagnostics(): string {
|
||||
return `stdout:\n${this.#stdout.slice(-8000)}\nstderr:\n${this.#stderr.slice(-8000)}`;
|
||||
}
|
||||
|
||||
async #pump(stream: ReadableStream<Uint8Array>, consume: (text: string) => void): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) consume(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
consume(decoder.decode());
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
#handleStdout(text: string): void {
|
||||
this.#stdout += text;
|
||||
const lines = (this.#stdoutLineBuffer + text).split(/\r?\n/);
|
||||
this.#stdoutLineBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith(FRAME_PREFIX)) continue;
|
||||
try {
|
||||
const frame = JSON.parse(line.slice(FRAME_PREFIX.length)) as FixtureReady | FixtureResponse;
|
||||
if (frame.type === "ready") {
|
||||
this.#resolveReady(frame);
|
||||
} else if (frame.type === "response") {
|
||||
const pending = this.#pending.get(frame.id);
|
||||
if (!pending) continue;
|
||||
this.#pending.delete(frame.id);
|
||||
pending.resolve(frame);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#rejectReady(new Error(`Could not parse a P2P automatic-host fixture response: ${String(error)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ready(): Promise<FixtureReady> {
|
||||
return await this.#ready;
|
||||
}
|
||||
|
||||
async request<T>(action: string, fields: Record<string, unknown> = {}, timeoutMs = 30000): Promise<T> {
|
||||
await this.#ready;
|
||||
const id = String(++this.#nextId);
|
||||
const responsePromise = new Promise<FixtureResponse>((resolve, reject) => {
|
||||
this.#pending.set(id, { resolve, reject });
|
||||
});
|
||||
try {
|
||||
await this.#stdinWriter.write(new TextEncoder().encode(`${JSON.stringify({ id, action, ...fields })}\n`));
|
||||
const response = await withTimeout(responsePromise, timeoutMs, `waiting for fixture action '${action}'`);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error ?? `Fixture action '${action}' failed`);
|
||||
}
|
||||
return response.result as T;
|
||||
} catch (error) {
|
||||
this.#pending.delete(id);
|
||||
throw new Error(`${String(error)}\n${this.diagnostics}`);
|
||||
}
|
||||
}
|
||||
|
||||
async state(): Promise<FixtureState> {
|
||||
return await this.request<FixtureState>("state");
|
||||
}
|
||||
|
||||
async waitForPeer(peerName: string, timeoutMs: number): Promise<void> {
|
||||
await this.request("wait-peer", { peerName, timeoutMs }, timeoutMs + 3000);
|
||||
}
|
||||
|
||||
async write(path: string, content: string): Promise<void> {
|
||||
await this.request("write", { path, content });
|
||||
}
|
||||
|
||||
async waitForContent(note: { path: string; content: string }, timeoutMs: number): Promise<void> {
|
||||
await this.request(
|
||||
"wait-content",
|
||||
{
|
||||
path: note.path,
|
||||
content: note.content,
|
||||
timeoutMs,
|
||||
},
|
||||
timeoutMs + 3000
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<number> {
|
||||
if (this.#stopped) return (await this.#status).code;
|
||||
try {
|
||||
await this.request("stop", {}, 5000);
|
||||
this.#stopped = true;
|
||||
const status = await withTimeout(this.#status, 12000, "waiting for the P2P fixture to stop gracefully");
|
||||
await Promise.all([this.#stdoutTask, this.#stderrTask]);
|
||||
return status.code;
|
||||
} catch (error) {
|
||||
await this.#kill().catch(() => {});
|
||||
throw new Error(`${String(error)}\n${this.diagnostics}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #kill(): Promise<void> {
|
||||
this.#stopped = true;
|
||||
try {
|
||||
this.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
try {
|
||||
await withTimeout(this.#status, 3000, "waiting for the P2P fixture to exit");
|
||||
} catch {
|
||||
try {
|
||||
this.child.kill("SIGKILL");
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
await this.#status;
|
||||
}
|
||||
await Promise.all([this.#stdoutTask, this.#stderrTask]);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureAutomaticSettings(
|
||||
settingsPath: string,
|
||||
peerName: string,
|
||||
roomId: string,
|
||||
passphrase: string,
|
||||
relay: string,
|
||||
turnServers: string,
|
||||
autoAcceptingPeers: string,
|
||||
autoSyncPeers: string,
|
||||
autoWatchPeers: string
|
||||
): Promise<void> {
|
||||
await initSettingsFile(settingsPath);
|
||||
await applyP2pSettings(
|
||||
settingsPath,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-automatic-host-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(settingsPath, peerName, passphrase);
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8"));
|
||||
settings.P2P_AutoAccepting = 0;
|
||||
settings.P2P_AutoStart = false;
|
||||
settings.P2P_AutoBroadcast = true;
|
||||
settings.P2P_AutoSyncPeers = autoSyncPeers;
|
||||
settings.P2P_AutoWatchPeers = autoWatchPeers;
|
||||
settings.P2P_AutoAcceptingPeers = autoAcceptingPeers;
|
||||
settings.P2P_AutoDenyingPeers = "";
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
test("p2p-host: restored automatic policy syncs clients through a persistent middle peer", async () => {
|
||||
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||
const peersTimeoutMs = Number(process.env["PEERS_TIMEOUT"] ?? "20") * 1000;
|
||||
const syncTimeoutMs = Number(process.env["SYNC_TIMEOUT"] ?? "60") * 1000;
|
||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const roomId = `automatic-host-room-${nonce}`;
|
||||
const passphrase = `automatic-host-pass-${nonce}`;
|
||||
const hostPeerName = `p2p-auto-host-${nonce}`;
|
||||
const clientAPeerName = `p2p-auto-client-a-${nonce}`;
|
||||
const clientBPeerName = `p2p-auto-client-b-${nonce}`;
|
||||
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? "turn:127.0.0.1:3478" : "none");
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-automatic-host");
|
||||
const hostVault = workDir.join("vault-host");
|
||||
const clientAVault = workDir.join("vault-client-a");
|
||||
const clientBVault = workDir.join("vault-client-b");
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientASettings = workDir.join("settings-client-a.json");
|
||||
const clientBSettings = workDir.join("settings-client-b.json");
|
||||
await Promise.all([
|
||||
mkdir(hostVault, { recursive: true }),
|
||||
mkdir(clientAVault, { recursive: true }),
|
||||
mkdir(clientBVault, { recursive: true }),
|
||||
]);
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
let host: AutomaticP2PHostProcess | undefined;
|
||||
let clientA: AutomaticP2PHostProcess | undefined;
|
||||
let clientB: AutomaticP2PHostProcess | undefined;
|
||||
try {
|
||||
const hostPeerPattern = "~p2p-auto-client-.*";
|
||||
await configureAutomaticSettings(
|
||||
hostSettings,
|
||||
hostPeerName,
|
||||
roomId,
|
||||
passphrase,
|
||||
relay,
|
||||
turnServers,
|
||||
hostPeerPattern,
|
||||
hostPeerPattern,
|
||||
hostPeerPattern
|
||||
);
|
||||
for (const [settingsPath, peerName] of [
|
||||
[clientASettings, clientAPeerName],
|
||||
[clientBSettings, clientBPeerName],
|
||||
]) {
|
||||
await configureAutomaticSettings(
|
||||
settingsPath,
|
||||
peerName,
|
||||
roomId,
|
||||
passphrase,
|
||||
relay,
|
||||
turnServers,
|
||||
hostPeerName,
|
||||
hostPeerName,
|
||||
hostPeerName
|
||||
);
|
||||
}
|
||||
|
||||
host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, HOST_BASELINE, 20000);
|
||||
const initialHostState = await host.state();
|
||||
assert(initialHostState.connected, "p2p-host did not explicitly connect while P2P_AutoStart was false");
|
||||
assert.deepStrictEqual(
|
||||
initialHostState.policy,
|
||||
{
|
||||
P2P_Enabled: true,
|
||||
P2P_AutoAccepting: 0,
|
||||
P2P_AutoStart: false,
|
||||
P2P_AutoBroadcast: true,
|
||||
P2P_AutoSyncPeers: hostPeerPattern,
|
||||
P2P_AutoWatchPeers: hostPeerPattern,
|
||||
},
|
||||
"The host did not restore its original P2P policy before serving peers"
|
||||
);
|
||||
|
||||
clientA = await AutomaticP2PHostProcess.start(clientAVault, clientASettings, CLIENT_A_BASELINE, 20000);
|
||||
await host.waitForPeer(clientAPeerName, peersTimeoutMs);
|
||||
await clientA.waitForPeer(hostPeerName, peersTimeoutMs);
|
||||
|
||||
await host.waitForContent(CLIENT_A_BASELINE, syncTimeoutMs);
|
||||
await clientA.waitForContent(HOST_BASELINE, syncTimeoutMs);
|
||||
|
||||
await clientA.write(CLIENT_A_UPDATE.path, CLIENT_A_UPDATE.content);
|
||||
await host.waitForContent(CLIENT_A_UPDATE, syncTimeoutMs);
|
||||
await host.write(HOST_UPDATE.path, HOST_UPDATE.content);
|
||||
await clientA.waitForContent(HOST_UPDATE, syncTimeoutMs);
|
||||
|
||||
assert.deepStrictEqual(await clientA.stop(), 0, "Client A did not stop cleanly before going offline");
|
||||
clientA = undefined;
|
||||
|
||||
clientB = await AutomaticP2PHostProcess.start(clientBVault, clientBSettings, undefined, 20000);
|
||||
await host.waitForPeer(clientBPeerName, peersTimeoutMs);
|
||||
await clientB.waitForPeer(hostPeerName, peersTimeoutMs);
|
||||
await clientB.waitForContent(CLIENT_A_BASELINE, syncTimeoutMs);
|
||||
await clientB.waitForContent(CLIENT_A_UPDATE, syncTimeoutMs);
|
||||
await clientB.waitForContent(HOST_BASELINE, syncTimeoutMs);
|
||||
await clientB.waitForContent(HOST_UPDATE, syncTimeoutMs);
|
||||
|
||||
await clientB.write(CLIENT_B_UPDATE.path, CLIENT_B_UPDATE.content);
|
||||
await host.waitForContent(CLIENT_B_UPDATE, syncTimeoutMs);
|
||||
|
||||
assert.deepStrictEqual(await host.stop(), 0, "The persistent host did not stop cleanly for its restart check");
|
||||
host = undefined;
|
||||
await clientB.write(CLIENT_B_DURING_OUTAGE.path, CLIENT_B_DURING_OUTAGE.content);
|
||||
|
||||
host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, undefined, 20000);
|
||||
const restartedHostState = await host.state();
|
||||
assert(restartedHostState.connected, "The restarted p2p-host did not connect");
|
||||
assert.deepStrictEqual(
|
||||
restartedHostState.policy,
|
||||
initialHostState.policy,
|
||||
"The restarted host did not restore its original P2P policy"
|
||||
);
|
||||
await host.waitForPeer(clientBPeerName, peersTimeoutMs);
|
||||
await clientB.waitForPeer(hostPeerName, peersTimeoutMs);
|
||||
await host.waitForContent(CLIENT_B_UPDATE, syncTimeoutMs);
|
||||
await host.waitForContent(CLIENT_B_DURING_OUTAGE, syncTimeoutMs);
|
||||
await clientB.waitForContent(HOST_BASELINE, syncTimeoutMs);
|
||||
await clientB.waitForContent(HOST_UPDATE, syncTimeoutMs);
|
||||
} finally {
|
||||
await Promise.allSettled([host?.stop(), clientA?.stop(), clientB?.stop()].filter(Boolean) as Promise<number>[]);
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { initSettingsFile, applyP2pSettings } from "./helpers/settings.ts";
|
||||
import { startP2pRelay, stopP2pRelay, isLocalP2pRelay } from "./helpers/docker.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
|
||||
test("p2p-host: starts and becomes ready", async () => {
|
||||
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||
const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests";
|
||||
const useInternalRelay = process.env["USE_INTERNAL_RELAY"] !== "0";
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-host");
|
||||
const vaultDir = workDir.join("vault-host");
|
||||
const settingsFile = workDir.join("settings-host.json");
|
||||
await mkdir(vaultDir, { recursive: true });
|
||||
|
||||
let relayStarted = false;
|
||||
if (useInternalRelay && isLocalP2pRelay(relay)) {
|
||||
await startP2pRelay();
|
||||
relayStarted = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsFile);
|
||||
await applyP2pSettings(settingsFile, roomId, passphrase, appId, relay);
|
||||
const host = startCliInBackground(vaultDir, "--settings", settingsFile, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
assert(host.combined.includes("P2P host is running"));
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
if (relayStarted) {
|
||||
await stopP2pRelay().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
maybeStartLocalRelay,
|
||||
stopLocalRelayIfStarted,
|
||||
maybeStartCoturn,
|
||||
stopCoturnIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { getOptimalLoopbackIp } from "./helpers/net.ts";
|
||||
|
||||
test("p2p-peers: discovers host through local relay", async () => {
|
||||
const loopbackIp = await getOptimalLoopbackIp();
|
||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||
|
||||
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||
const timeoutSeconds = Number(process.env["TIMEOUT_SECONDS"] ?? "8");
|
||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
|
||||
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay");
|
||||
const hostVault = workDir.join("vault-host");
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientVault = workDir.join("vault");
|
||||
const clientSettings = workDir.join("settings.json");
|
||||
await mkdir(hostVault, { recursive: true });
|
||||
await mkdir(clientVault, { recursive: true });
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
await initSettingsFile(hostSettings);
|
||||
await initSettingsFile(clientSettings);
|
||||
await applyP2pSettings(
|
||||
hostSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pSettings(
|
||||
clientSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const peer = await discoverPeer(clientVault, clientSettings, timeoutSeconds, hostPeerName);
|
||||
assert(peer.id.length > 0);
|
||||
assert(peer.name.length > 0);
|
||||
assert(peer.name === hostPeerName, `expected peer '${hostPeerName}', got '${peer.name}'`);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "node:test";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runProcess } from "./helpers/process.ts";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("p2p lifecycle: explicit disconnect closes and pauses relay WebSockets", async () => {
|
||||
const result = await runProcess(process.execPath, {
|
||||
args: [fileURLToPath(new URL("./relay-disconnect-probe.mjs", import.meta.url))],
|
||||
env: {
|
||||
RELAY: process.env["RELAY"] ?? "ws://nostr-relay:7777/",
|
||||
RELAY_TIMEOUT_MS: process.env["LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS"] ?? "15000",
|
||||
},
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout).trim();
|
||||
const stderr = new TextDecoder().decode(result.stderr).trim();
|
||||
|
||||
assert(result.success, `Relay disconnect probe failed\nstdout: ${stdout}\nstderr: ${stderr}`);
|
||||
const report = JSON.parse(stdout.split("\n").at(-1) ?? "{}") as {
|
||||
socketsClosed?: number;
|
||||
stayedDisconnectedWhilePaused?: boolean;
|
||||
reconnectedAfterResume?: boolean;
|
||||
};
|
||||
assert((report.socketsClosed ?? 0) > 0, "The probe did not observe an open relay WebSocket");
|
||||
assert.deepStrictEqual(report.stayedDisconnectedWhilePaused, true);
|
||||
assert.deepStrictEqual(report.reconnectedAfterResume, true);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from "node:test";
|
||||
import process from "node:process";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { runProcess } from "./helpers/process.ts";
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import { maybeStartLocalRelay, stopLocalRelayIfStarted, maybeStartCoturn, stopCoturnIfStarted } from "./helpers/p2p.ts";
|
||||
import { CLI_DIR, runCli, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
|
||||
function assertStringIncludes(actual: string, expected: string, message?: string): void {
|
||||
assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
const NOTE_PATH = "p2p-replicator-replacement.md";
|
||||
const NOTE_CONTENT = "Replicated after replacing the active P2P replicator.";
|
||||
|
||||
async function runReplacementProbe(
|
||||
vaultPath: string,
|
||||
settingsPath: string,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
const result = await runProcess(process.execPath, {
|
||||
args: [
|
||||
join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"),
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
"p2p-sync",
|
||||
targetPeer,
|
||||
String(timeoutMs / 1000),
|
||||
NOTE_PATH,
|
||||
NOTE_CONTENT,
|
||||
],
|
||||
cwd: CLI_DIR,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
return {
|
||||
code: result.code,
|
||||
stdout: new TextDecoder().decode(result.stdout),
|
||||
stderr: new TextDecoder().decode(result.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
|
||||
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20");
|
||||
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "60");
|
||||
const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000;
|
||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const roomId = process.env["ROOM_ID"] ?? `replacement-room-${nonce}`;
|
||||
const passphrase = process.env["PASSPHRASE"] ?? `replacement-pass-${nonce}`;
|
||||
const appId = "self-hosted-livesync-cli-replacement-test";
|
||||
const hostPeerName = `p2p-replacement-host-${nonce}`;
|
||||
const probePeerName = `p2p-replacement-probe-${nonce}`;
|
||||
const verifierPeerName = `p2p-replacement-verifier-${nonce}`;
|
||||
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? "turn:127.0.0.1:3478" : "none");
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-replacement");
|
||||
const hostVault = workDir.join("vault-host");
|
||||
const probeVault = workDir.join("vault-probe");
|
||||
const verifierVault = workDir.join("vault-verifier");
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const probeSettings = workDir.join("settings-probe.json");
|
||||
const verifierSettings = workDir.join("settings-verifier.json");
|
||||
await Promise.all([
|
||||
mkdir(hostVault, { recursive: true }),
|
||||
mkdir(probeVault, { recursive: true }),
|
||||
mkdir(verifierVault, { recursive: true }),
|
||||
]);
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
for (const settingsPath of [hostSettings, probeSettings, verifierSettings]) {
|
||||
await initSettingsFile(settingsPath);
|
||||
await applyP2pSettings(settingsPath, roomId, passphrase, appId, relay, "~.*", turnServers);
|
||||
}
|
||||
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(probeSettings, probePeerName, passphrase);
|
||||
await applyP2pTestTweaks(verifierSettings, verifierPeerName, passphrase);
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs);
|
||||
assert(probe.code === 0, `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`);
|
||||
assertStringIncludes(probe.stdout, "[Probe] The active P2P adapter was replaced");
|
||||
|
||||
const syncResult = await runCli(
|
||||
verifierVault,
|
||||
"--settings",
|
||||
verifierSettings,
|
||||
"p2p-sync",
|
||||
hostPeerName,
|
||||
String(syncTimeout)
|
||||
);
|
||||
assert(
|
||||
syncResult.code === 0,
|
||||
`Verifier P2P sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
|
||||
);
|
||||
|
||||
const catResult = await runCli(verifierVault, "--settings", verifierSettings, "cat", NOTE_PATH);
|
||||
assert(
|
||||
catResult.code === 0,
|
||||
`Verifier could not read ${NOTE_PATH}\nstdout: ${catResult.stdout}\nstderr: ${catResult.stderr}`
|
||||
);
|
||||
assert.deepStrictEqual(sanitiseCatStdout(catResult.stdout).trim(), NOTE_CONTENT);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import {
|
||||
initSettingsFile,
|
||||
applyP2pSettings,
|
||||
applyP2pTestTweaks,
|
||||
generateSetupUriFromSettings,
|
||||
} from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
maybeStartLocalRelay,
|
||||
stopLocalRelayIfStarted,
|
||||
maybeStartCoturn,
|
||||
stopCoturnIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { getOptimalLoopbackIp } from "./helpers/net.ts";
|
||||
|
||||
test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
|
||||
const loopbackIp = await getOptimalLoopbackIp();
|
||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||
|
||||
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "12");
|
||||
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
|
||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
|
||||
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-sync");
|
||||
const hostVault = workDir.join("vault-host");
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientVault = workDir.join("vault-sync");
|
||||
const clientSettings = workDir.join("settings-sync.json");
|
||||
const rejectedVault = workDir.join("vault-rejected");
|
||||
const rejectedSettings = workDir.join("settings-rejected.json");
|
||||
await mkdir(hostVault, { recursive: true });
|
||||
await mkdir(clientVault, { recursive: true });
|
||||
await mkdir(rejectedVault, { recursive: true });
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
await initSettingsFile(hostSettings);
|
||||
await initSettingsFile(clientSettings);
|
||||
await initSettingsFile(rejectedSettings);
|
||||
await applyP2pSettings(
|
||||
hostSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pSettings(
|
||||
clientSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pSettings(
|
||||
rejectedSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
|
||||
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
|
||||
for (const [vault, path, key, label] of [
|
||||
[hostVault, hostSettings, "ab".repeat(32), "host"],
|
||||
[clientVault, clientSettings, "ab".repeat(32), "client"],
|
||||
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
|
||||
]) {
|
||||
const settings = JSON.parse(await readFile(path, "utf8"));
|
||||
settings.idDerivationVersion = 1;
|
||||
settings.idDerivationKey = key;
|
||||
const sourcePath = workDir.join("setup-source-" + label + ".json");
|
||||
await writeFile(sourcePath, JSON.stringify(settings));
|
||||
const setupPassphrase = "independent-id-setup-passphrase";
|
||||
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
|
||||
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
|
||||
const persisted = JSON.parse(await readFile(path, "utf8"));
|
||||
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
|
||||
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
|
||||
assert(
|
||||
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
|
||||
"The CLI did not encrypt the saved ID key."
|
||||
);
|
||||
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
|
||||
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
|
||||
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
|
||||
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
|
||||
}
|
||||
const notePath = "p2p/independent-id-note.md";
|
||||
await runCliWithInputOrFail(
|
||||
"A note transferred with the saved ID key.\n",
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"put",
|
||||
notePath
|
||||
);
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const peer = await discoverPeer(
|
||||
clientVault,
|
||||
clientSettings,
|
||||
peersTimeout,
|
||||
process.env["TARGET_PEER"] ?? hostPeerName
|
||||
);
|
||||
const syncResult = await runCli(
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"p2p-sync",
|
||||
peer.id,
|
||||
String(syncTimeout)
|
||||
);
|
||||
assert(
|
||||
syncResult.code === 0,
|
||||
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
|
||||
);
|
||||
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
|
||||
const rejectedSync = await runCli(
|
||||
rejectedVault,
|
||||
"--settings",
|
||||
rejectedSettings,
|
||||
"p2p-sync",
|
||||
rejectedPeer.id,
|
||||
String(syncTimeout)
|
||||
);
|
||||
assert(
|
||||
rejectedSync.code !== 0,
|
||||
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
assert(
|
||||
rejectedSync.combined.includes("Tweak values are not matched"),
|
||||
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
const received = sanitiseCatStdout(
|
||||
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
|
||||
).trimEnd();
|
||||
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
|
||||
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
|
||||
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
maybeStartLocalRelay,
|
||||
stopLocalRelayIfStarted,
|
||||
maybeStartCoturn,
|
||||
stopCoturnIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { getOptimalLoopbackIp } from "./helpers/net.ts";
|
||||
|
||||
test("p2p: three nodes detect and resolve conflicts", async () => {
|
||||
const loopbackIp = await getOptimalLoopbackIp();
|
||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||
|
||||
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||
const appId = "self-hosted-livesync-cli-tests";
|
||||
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "10");
|
||||
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
|
||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||
const peerNameB = process.env["PEER_NAME_B"] ?? `p2p-client-b-${nonce}`;
|
||||
const peerNameC = process.env["PEER_NAME_C"] ?? `p2p-client-c-${nonce}`;
|
||||
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-3nodes");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const vaultC = workDir.join("vault-c");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const settingsC = workDir.join("settings-c.json");
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
await mkdir(vaultC, { recursive: true });
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
await initSettingsFile(settingsC);
|
||||
await applyP2pSettings(settingsA, roomId, passphrase, appId, relay, "~.*", turnServers);
|
||||
await applyP2pSettings(settingsB, roomId, passphrase, appId, relay, "~.*", turnServers);
|
||||
await applyP2pSettings(settingsC, roomId, passphrase, appId, relay, "~.*", turnServers);
|
||||
await applyP2pTestTweaks(settingsA, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(settingsB, peerNameB, passphrase);
|
||||
await applyP2pTestTweaks(settingsC, peerNameC, passphrase);
|
||||
|
||||
const host = startCliInBackground(vaultA, "--settings", settingsA, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const peerFromB = await discoverPeer(vaultB, settingsB, peersTimeout, hostPeerName);
|
||||
const peerFromC = await discoverPeer(vaultC, settingsC, peersTimeout, hostPeerName);
|
||||
const targetPath = "p2p/conflicted-from-two-clients.txt";
|
||||
|
||||
await runCliWithInputOrFail("from-client-b-v1\n", vaultB, "--settings", settingsB, "put", targetPath);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "p2p-sync", peerFromB.id, String(syncTimeout));
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "p2p-sync", peerFromC.id, String(syncTimeout));
|
||||
|
||||
let visibleOnC = "";
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
visibleOnC = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "cat", targetPath)
|
||||
).trimEnd();
|
||||
if (visibleOnC === "from-client-b-v1") break;
|
||||
} catch {
|
||||
// retry below
|
||||
}
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "p2p-sync", peerFromC.id, String(syncTimeout));
|
||||
}
|
||||
assert(visibleOnC === "from-client-b-v1", `C should see file created by B, got: ${visibleOnC}`);
|
||||
|
||||
await runCliWithInputOrFail("from-client-b-v2\n", vaultB, "--settings", settingsB, "put", targetPath);
|
||||
await runCliWithInputOrFail("from-client-c-v2\n", vaultC, "--settings", settingsC, "put", targetPath);
|
||||
|
||||
const [syncB, syncC] = await Promise.all([
|
||||
runCliOrFail(vaultB, "--settings", settingsB, "p2p-sync", peerFromB.id, String(syncTimeout)),
|
||||
runCliOrFail(vaultC, "--settings", settingsC, "p2p-sync", peerFromC.id, String(syncTimeout)),
|
||||
]);
|
||||
void syncB;
|
||||
void syncC;
|
||||
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "p2p-sync", peerFromB.id, String(syncTimeout));
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "p2p-sync", peerFromC.id, String(syncTimeout));
|
||||
|
||||
const infoBBefore = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetPath);
|
||||
const conflictsBBefore = jsonStringField(infoBBefore, "conflicts");
|
||||
const keepRevB = jsonStringField(infoBBefore, "revision");
|
||||
assert(
|
||||
conflictsBBefore !== "N/A" && conflictsBBefore.length > 0,
|
||||
`expected conflicts on B\n${infoBBefore}`
|
||||
);
|
||||
assert(keepRevB.length > 0, `could not read revision on B\n${infoBBefore}`);
|
||||
|
||||
const infoCBefore = await runCliOrFail(vaultC, "--settings", settingsC, "info", targetPath);
|
||||
const conflictsCBefore = jsonStringField(infoCBefore, "conflicts");
|
||||
const keepRevC = jsonStringField(infoCBefore, "revision");
|
||||
assert(
|
||||
conflictsCBefore !== "N/A" && conflictsCBefore.length > 0,
|
||||
`expected conflicts on C\n${infoCBefore}`
|
||||
);
|
||||
assert(keepRevC.length > 0, `could not read revision on C\n${infoCBefore}`);
|
||||
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "resolve", targetPath, keepRevB);
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "resolve", targetPath, keepRevC);
|
||||
|
||||
const infoBAfter = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetPath);
|
||||
const infoCAfter = await runCliOrFail(vaultC, "--settings", settingsC, "info", targetPath);
|
||||
assert(jsonStringField(infoBAfter, "conflicts") === "N/A", `conflict still remains on B\n${infoBAfter}`);
|
||||
assert(jsonStringField(infoCAfter, "conflicts") === "N/A", `conflict still remains on C\n${infoCAfter}`);
|
||||
|
||||
const finalContentB = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetPath)
|
||||
).trimEnd();
|
||||
const finalContentC = sanitiseCatStdout(
|
||||
await runCliOrFail(vaultC, "--settings", settingsC, "cat", targetPath)
|
||||
).trimEnd();
|
||||
assert(
|
||||
finalContentB === "from-client-b-v2" || finalContentB === "from-client-c-v2",
|
||||
`unexpected final content on B: ${finalContentB}`
|
||||
);
|
||||
assert(
|
||||
finalContentC === "from-client-b-v2" || finalContentC === "from-client-c-v2",
|
||||
`unexpected final content on C: ${finalContentC}`
|
||||
);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test } from "node:test";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts";
|
||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||
|
||||
async function writeFilledFile(path: string, size: number, byte: number): Promise<void> {
|
||||
const data = new Uint8Array(size);
|
||||
data.fill(byte);
|
||||
await writeFile(path, data);
|
||||
}
|
||||
|
||||
test("p2p: upload/download reproduction scenario", async () => {
|
||||
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||
const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests";
|
||||
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20");
|
||||
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "240");
|
||||
const roomId = `p2p-room-${Date.now()}`;
|
||||
const passphrase = `p2p-pass-${Date.now()}`;
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-p2p-upload-download");
|
||||
const vaultHost = workDir.join("vault-host");
|
||||
const vaultUp = workDir.join("vault-up");
|
||||
const vaultDown = workDir.join("vault-down");
|
||||
const settingsHost = workDir.join("settings-host.json");
|
||||
const settingsUp = workDir.join("settings-up.json");
|
||||
const settingsDown = workDir.join("settings-down.json");
|
||||
for (const dir of [vaultHost, vaultUp, vaultDown]) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
try {
|
||||
for (const settings of [settingsHost, settingsUp, settingsDown]) {
|
||||
await initSettingsFile(settings);
|
||||
await applyP2pSettings(settings, roomId, passphrase, appId, relay, "~.*");
|
||||
}
|
||||
await applyP2pTestTweaks(settingsHost, "p2p-cli-host", passphrase);
|
||||
await applyP2pTestTweaks(settingsUp, `p2p-cli-upload-${Date.now()}`, passphrase);
|
||||
await applyP2pTestTweaks(settingsDown, `p2p-cli-download-${Date.now()}`, passphrase);
|
||||
|
||||
const host = startCliInBackground(vaultHost, "--settings", settingsHost, "p2p-host");
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const uploadPeer = await discoverPeer(vaultUp, settingsUp, peersTimeout);
|
||||
|
||||
const storeText = workDir.join("store-file.md");
|
||||
const diffA = workDir.join("test-diff-1.md");
|
||||
const diffB = workDir.join("test-diff-2.md");
|
||||
const diffC = workDir.join("test-diff-3.md");
|
||||
await writeFile(storeText, "Hello, World!\n");
|
||||
await writeFile(diffA, "Content A\n");
|
||||
await writeFile(diffB, "Content B\n");
|
||||
await writeFile(diffC, "Content C\n");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", storeText, "p2p/store-file.md");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffA, "p2p/test-diff-1.md");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffB, "p2p/test-diff-2.md");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffC, "p2p/test-diff-3.md");
|
||||
|
||||
const large100k = workDir.join("large-100k.txt");
|
||||
const large1m = workDir.join("large-1m.txt");
|
||||
const binary100k = workDir.join("binary-100k.bin");
|
||||
const binary5m = workDir.join("binary-5m.bin");
|
||||
await writeFile(large100k, "a".repeat(100000));
|
||||
await writeFile(large1m, "b".repeat(1000000));
|
||||
await writeFilledFile(binary100k, 100000, 0x5a);
|
||||
await writeFilledFile(binary5m, 5000000, 0x7c);
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", large100k, "p2p/large-100000.md");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", large1m, "p2p/large-1000000.md");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", binary100k, "p2p/binary-100000.bin");
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", binary5m, "p2p/binary-5000000.bin");
|
||||
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "p2p-sync", uploadPeer.id, String(syncTimeout));
|
||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "p2p-sync", uploadPeer.id, String(syncTimeout));
|
||||
|
||||
const downloadPeer = await discoverPeer(vaultDown, settingsDown, peersTimeout);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "p2p-sync", downloadPeer.id, String(syncTimeout));
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "p2p-sync", downloadPeer.id, String(syncTimeout));
|
||||
|
||||
const downStoreText = workDir.join("down-store-file.md");
|
||||
const downDiffA = workDir.join("down-test-diff-1.md");
|
||||
const downDiffB = workDir.join("down-test-diff-2.md");
|
||||
const downDiffC = workDir.join("down-test-diff-3.md");
|
||||
const downLarge100k = workDir.join("down-large-100k.txt");
|
||||
const downLarge1m = workDir.join("down-large-1m.txt");
|
||||
const downBinary100k = workDir.join("down-binary-100k.bin");
|
||||
const downBinary5m = workDir.join("down-binary-5m.bin");
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/store-file.md", downStoreText);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/test-diff-1.md", downDiffA);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/test-diff-2.md", downDiffB);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/test-diff-3.md", downDiffC);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/large-100000.md", downLarge100k);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/large-1000000.md", downLarge1m);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/binary-100000.bin", downBinary100k);
|
||||
await runCliOrFail(vaultDown, "--settings", settingsDown, "pull", "p2p/binary-5000000.bin", downBinary5m);
|
||||
|
||||
await assertFilesEqual(storeText, downStoreText, "store-file mismatch");
|
||||
await assertFilesEqual(diffA, downDiffA, "test-diff-1 mismatch");
|
||||
await assertFilesEqual(diffB, downDiffB, "test-diff-2 mismatch");
|
||||
await assertFilesEqual(diffC, downDiffC, "test-diff-3 mismatch");
|
||||
await assertFilesEqual(large100k, downLarge100k, "large-100000 mismatch");
|
||||
await assertFilesEqual(large1m, downLarge1m, "large-1000000 mismatch");
|
||||
await assertFilesEqual(binary100k, downBinary100k, "binary-100000 mismatch");
|
||||
await assertFilesEqual(binary5m, downBinary5m, "binary-5000000 mismatch");
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Node.js port of test-push-pull-linux.sh
|
||||
*
|
||||
* Requires CouchDB connection details either via environment variables or a
|
||||
* .test.env file. If neither is present the test logs a warning and the
|
||||
* CLI will likely fail at the push step.
|
||||
*
|
||||
* Run:
|
||||
* node --test test-push-pull.ts
|
||||
*
|
||||
* With explicit CouchDB:
|
||||
* COUCHDB_URI=http://127.0.0.1:5984 \
|
||||
* COUCHDB_USER=admin \
|
||||
* COUCHDB_PASSWORD=password \
|
||||
* COUCHDB_DBNAME=livesync-test \
|
||||
* node --test test-push-pull.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
|
||||
const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/push-pull.txt";
|
||||
|
||||
test("push/pull roundtrip", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-push-pull");
|
||||
|
||||
const settingsFile = workDir.join("data.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
await mkdir(join(vaultDir, "test"), { recursive: true });
|
||||
|
||||
const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||
const dbname = process.env["COUCHDB_DBNAME"] ?? `push-pull-${Date.now()}`;
|
||||
|
||||
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
|
||||
if (shouldStartDocker) {
|
||||
await startCouchdb(uri, user, password, dbname);
|
||||
}
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsFile);
|
||||
|
||||
if (uri && user && password && dbname) {
|
||||
console.log("[INFO] applying CouchDB env vars to settings");
|
||||
await applyCouchdbSettings(settingsFile, uri, user, password, dbname);
|
||||
} else {
|
||||
console.warn(
|
||||
"[WARN] CouchDB env vars not fully set — push/pull may fail unless the generated settings already contain connection details"
|
||||
);
|
||||
}
|
||||
|
||||
const srcFile = workDir.join("push-source.txt");
|
||||
const pulledFile = workDir.join("pull-result.txt");
|
||||
const content = `push-pull-test ${new Date().toISOString()}\n`;
|
||||
await writeFile(srcFile, content);
|
||||
|
||||
console.log(`[INFO] push -> ${REMOTE_PATH}`);
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "push", srcFile, REMOTE_PATH);
|
||||
|
||||
console.log(`[INFO] pull <- ${REMOTE_PATH}`);
|
||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
||||
|
||||
const pulled = await readFile(pulledFile, "utf8");
|
||||
assert.deepStrictEqual(content, pulled, "push/pull roundtrip content mismatch");
|
||||
console.log("[PASS] push/pull roundtrip matched");
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
function assertStringIncludes(actual: string, expected: string, message?: string): void {
|
||||
assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
async function prepareFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
test("remote administration process exit policy distinguishes returned verification failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyP2pSettings(
|
||||
settingsFile,
|
||||
"remote-admin-exit-room",
|
||||
"remote-admin-exit-passphrase",
|
||||
"remote-admin-exit-tests",
|
||||
"ws://127.0.0.1:1/",
|
||||
"~.*",
|
||||
"none"
|
||||
);
|
||||
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||
|
||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||
assert.deepStrictEqual(defaultFailure.code, 1, defaultFailure.combined);
|
||||
assertStringIncludes(
|
||||
defaultFailure.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(defaultFailure.combined, "[Error] Command 'mark-resolved' failed");
|
||||
|
||||
const compatibilitySuccess = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assert.deepStrictEqual(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||
assertStringIncludes(
|
||||
compatibilitySuccess.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
|
||||
});
|
||||
|
||||
test("remote administration compatibility does not hide a thrown mutation failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyCouchdbSettings(
|
||||
settingsFile,
|
||||
"http://127.0.0.1:1/",
|
||||
"unreachable-user",
|
||||
"unreachable-password",
|
||||
"unreachable-database"
|
||||
);
|
||||
|
||||
const mutationFailure = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assert.deepStrictEqual(mutationFailure.code, 1, mutationFailure.combined);
|
||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Node.js port of test-remote-commands-linux.sh
|
||||
*
|
||||
* Tests remote management commands: remote-status, lock-remote, unlock-remote,
|
||||
* and mark-resolved.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Start CouchDB, create a test database, and perform an initial sync.
|
||||
* 2. Run remote-status and assert that the output contains the database name in JSON format.
|
||||
* 3. Run lock-remote and verify that the remote database is locked.
|
||||
* 4. Lock the remote database milestone manually, verify status, and run unlock-remote.
|
||||
* Assert that the output of unlock-remote contains the unlocked verification status.
|
||||
* 5. Lock the remote database milestone manually, run mark-resolved, and verify that the
|
||||
* current device is accepted.
|
||||
*
|
||||
* Run:
|
||||
* node --test test-remote-commands.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli, assertContains } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
||||
|
||||
async function runCliCombinedOrFail(...args: string[]): Promise<string> {
|
||||
const res = await runCli(...args);
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`CLI exited with code ${res.code}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
|
||||
}
|
||||
return res.combined;
|
||||
}
|
||||
|
||||
test("remote management commands", async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-remote-cmds");
|
||||
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
await mkdir(vaultDir, { recursive: true });
|
||||
|
||||
const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const dbname = process.env["COUCHDB_DBNAME"] ?? `remotes-${dbSuffix}`;
|
||||
|
||||
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
|
||||
if (shouldStartDocker) {
|
||||
await startCouchdb(uri, user, password, dbname);
|
||||
}
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsFile);
|
||||
await applyCouchdbSettings(settingsFile, uri, user, password, dbname, true);
|
||||
|
||||
console.log("[INFO] Performing initial sync to create milestone document...");
|
||||
await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "sync");
|
||||
|
||||
// 1. remote-status outputs valid JSON with CouchDB details
|
||||
console.log("[CASE] remote-status outputs valid JSON with CouchDB details");
|
||||
const statusOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "remote-status");
|
||||
assertContains(statusOutput, `"db_name": "${dbname}"`, "remote-status should return JSON containing db_name");
|
||||
console.log("[PASS] remote-status verified");
|
||||
|
||||
// 2. lock-remote locks and verifies state
|
||||
console.log("[CASE] lock-remote locks and verifies state");
|
||||
const lockOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "lock-remote");
|
||||
assertContains(
|
||||
lockOutput,
|
||||
"[Verification] Remote Database: LOCKED",
|
||||
"lock-remote output should show that the remote database is locked"
|
||||
);
|
||||
console.log("[PASS] lock-remote verified");
|
||||
|
||||
// 3. unlock-remote unlocks and verifies state
|
||||
console.log("[CASE] unlock-remote unlocks and verifies state");
|
||||
// Manually lock milestone
|
||||
console.log("[INFO] Manually locking milestone...");
|
||||
await updateCouchdbDoc(uri, user, password, `${dbname}/_local/obsydian_livesync_milestone`, (doc) => {
|
||||
doc.locked = true;
|
||||
doc.accepted_nodes = [];
|
||||
return doc;
|
||||
});
|
||||
|
||||
// Run unlock-remote and verify output contains verification message
|
||||
const unlockOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "unlock-remote");
|
||||
assertContains(
|
||||
unlockOutput,
|
||||
"[Verification] Remote Database: UNLOCKED",
|
||||
"unlock-remote output should contain verification status"
|
||||
);
|
||||
console.log("[PASS] unlock-remote verified");
|
||||
|
||||
// 4. mark-resolved resolves and verifies state
|
||||
console.log("[CASE] mark-resolved resolves and verifies state");
|
||||
// Manually lock milestone
|
||||
console.log("[INFO] Manually locking milestone...");
|
||||
await updateCouchdbDoc(uri, user, password, `${dbname}/_local/obsydian_livesync_milestone`, (doc) => {
|
||||
doc.locked = true;
|
||||
doc.accepted_nodes = [];
|
||||
return doc;
|
||||
});
|
||||
|
||||
// Run mark-resolved and verify output contains verification messages
|
||||
const resolvedOutput = await runCliCombinedOrFail(vaultDir, "--settings", settingsFile, "mark-resolved");
|
||||
assertContains(
|
||||
resolvedOutput,
|
||||
"[Verification] Remote Database: LOCKED",
|
||||
"mark-resolved output should show that the remote database remains locked"
|
||||
);
|
||||
assertContains(
|
||||
resolvedOutput,
|
||||
"ACCEPTED",
|
||||
"mark-resolved output should show that the current device node is accepted"
|
||||
);
|
||||
console.log("[PASS] mark-resolved verified");
|
||||
|
||||
console.log("[ALL PASS] All remote CLI commands verified successfully");
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareSettingsFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
test("settings-changing commands persist durable settings without CLI runtime suspension", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-command");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
settings.liveSync = true;
|
||||
settings.syncOnStart = true;
|
||||
settings.periodicReplication = true;
|
||||
settings.P2P_Enabled = true;
|
||||
settings.P2P_AutoStart = true;
|
||||
settings.P2P_AutoBroadcast = true;
|
||||
await writeFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"test-remote",
|
||||
"sls+https://user:pass@example.com/database"
|
||||
);
|
||||
assert.deepStrictEqual(result.code, 0, result.combined);
|
||||
const firstRemoteId = result.stdout.trim().split("\t")[0];
|
||||
assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`);
|
||||
|
||||
let persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
const remotesByName = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
|
||||
assert(
|
||||
remotesByName.some((remote) => remote.name === "test-remote"),
|
||||
"remote-add did not persist the new profile"
|
||||
);
|
||||
assert.deepStrictEqual(persisted.liveSync, true);
|
||||
assert.deepStrictEqual(persisted.syncOnStart, true);
|
||||
assert.deepStrictEqual(persisted.periodicReplication, true);
|
||||
assert.deepStrictEqual(persisted.P2P_Enabled, true);
|
||||
assert.deepStrictEqual(persisted.P2P_AutoStart, true);
|
||||
assert.deepStrictEqual(persisted.P2P_AutoBroadcast, true);
|
||||
|
||||
const secondAdd = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"second-remote",
|
||||
"sls+https://other:secret@example.net/second"
|
||||
);
|
||||
assert.deepStrictEqual(secondAdd.code, 0, secondAdd.combined);
|
||||
const secondRemoteId = secondAdd.stdout.trim().split("\t")[0];
|
||||
assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`);
|
||||
|
||||
const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId);
|
||||
assert.deepStrictEqual(activate.code, 0, activate.combined);
|
||||
persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
assert.deepStrictEqual(persisted.activeConfigurationId, secondRemoteId);
|
||||
|
||||
const set = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-set",
|
||||
secondRemoteId,
|
||||
"sls+https://replacement:secret@example.org/replaced"
|
||||
);
|
||||
assert.deepStrictEqual(set.code, 0, set.combined);
|
||||
const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId);
|
||||
assert.deepStrictEqual(exported.code, 0, exported.combined);
|
||||
assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI");
|
||||
|
||||
const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId);
|
||||
assert.deepStrictEqual(remove.code, 0, remove.combined);
|
||||
persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
const remotesById = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
|
||||
assert(!remotesById.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
|
||||
assert.deepStrictEqual(persisted.activeConfigurationId, firstRemoteId);
|
||||
});
|
||||
|
||||
test("ordinary commands keep the settings file unchanged by default", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
settings.settingVersion = 9;
|
||||
const original = JSON.stringify(settings, null, 2);
|
||||
await writeFile(settingsFile, original);
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
|
||||
assert.deepStrictEqual(result.code, 0, result.combined);
|
||||
assert.deepStrictEqual(await readFile(settingsFile, "utf8"), original);
|
||||
});
|
||||
|
||||
test("--write-settings persists durable start-up setting changes", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
settings.settingVersion = 9;
|
||||
delete settings.useIndexedDBAdapter;
|
||||
await writeFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls");
|
||||
assert.deepStrictEqual(result.code, 0, result.combined);
|
||||
|
||||
const persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
assert.deepStrictEqual(persisted.settingVersion, 10);
|
||||
assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file");
|
||||
});
|
||||
|
||||
test("failed settings-changing commands leave the settings file unchanged", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-failure");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const original = await readFile(settingsFile, "utf8");
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote");
|
||||
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
|
||||
assert.deepStrictEqual(await readFile(settingsFile, "utf8"), original);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Node.js port of test-setup-put-cat-linux.sh
|
||||
*
|
||||
* Tests all local-DB file operations that require no external remote:
|
||||
* setup /
|
||||
* push / cat / ls / info / rm / resolve / cat-rev / pull-rev
|
||||
*
|
||||
* Run (no external services needed):
|
||||
* node --test test-setup-put-cat.ts
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli, runCliOrFail, runCliWithInput, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/setup-put-cat.txt";
|
||||
const SETUP_PASSPHRASE = process.env["SETUP_PASSPHRASE"] ?? "setup-passphrase";
|
||||
|
||||
test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev / pull-rev", async (t) => {
|
||||
await using workDir = await TempDir.create("livesync-cli-setup-put-cat");
|
||||
|
||||
const settingsFile = workDir.join("data.json");
|
||||
const vaultDir = workDir.join("vault");
|
||||
await mkdir(join(vaultDir, "test"), { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsFile);
|
||||
|
||||
const setupUri = await generateSetupUriFromSettings(settingsFile, SETUP_PASSPHRASE);
|
||||
const setupResult = await runCliWithInput(
|
||||
`${SETUP_PASSPHRASE}\n`,
|
||||
vaultDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"setup",
|
||||
setupUri
|
||||
);
|
||||
assert(setupResult.code === 0, `setup command exited with ${setupResult.code}\n${setupResult.combined}`);
|
||||
assert(
|
||||
setupResult.combined.includes("[Command] setup ->"),
|
||||
`setup command did not execute expected code path\n${setupResult.combined}`
|
||||
);
|
||||
const persistedSetup = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
assert.deepStrictEqual(persistedSetup.isConfigured, true, "setup did not persist the configured state");
|
||||
assert(
|
||||
typeof persistedSetup.encryptedCouchDBConnection === "string" &&
|
||||
persistedSetup.encryptedCouchDBConnection.length > 0,
|
||||
"setup did not persist the encrypted connection settings"
|
||||
);
|
||||
|
||||
const run = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// push / cat roundtrip
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("push/cat roundtrip", async () => {
|
||||
const srcFile = workDir.join("put-source.txt");
|
||||
const content = `setup-put-cat-test ${new Date().toISOString()}\nline-2\n`;
|
||||
await writeFile(srcFile, content);
|
||||
|
||||
console.log(`[INFO] push -> ${REMOTE_PATH}`);
|
||||
await runCliWithInput(content, vaultDir, "--settings", settingsFile, "put", REMOTE_PATH);
|
||||
|
||||
console.log(`[INFO] cat <- ${REMOTE_PATH}`);
|
||||
const rawOutput = await run("cat", REMOTE_PATH);
|
||||
const catOutput = sanitiseCatStdout(rawOutput);
|
||||
|
||||
assert.deepStrictEqual(content, catOutput, "push/cat roundtrip content mismatch");
|
||||
console.log("[PASS] push/cat roundtrip matched");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ls: single file
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("ls output format (single file)", async () => {
|
||||
const lsOutput = await run("ls", REMOTE_PATH);
|
||||
const line = lsOutput
|
||||
.trim()
|
||||
.split("\n")
|
||||
.find((l) => l.startsWith(REMOTE_PATH + "\t"));
|
||||
assert(line, `ls output did not include ${REMOTE_PATH}`);
|
||||
|
||||
const [lsPath, lsSize, lsMtime, lsRev] = line.split("\t");
|
||||
assert.deepStrictEqual(lsPath, REMOTE_PATH, "ls path column mismatch");
|
||||
assert(/^\d+$/.test(lsSize), `ls size not numeric: ${lsSize}`);
|
||||
assert(/^\d+$/.test(lsMtime), `ls mtime not numeric: ${lsMtime}`);
|
||||
assert(lsRev?.length > 0, "ls revision column is empty");
|
||||
console.log("[PASS] ls output format matched");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ls: prefix filter and sort order
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("ls prefix filter and sort order", async () => {
|
||||
await runCliWithInput("file-a\n", vaultDir, "--settings", settingsFile, "put", "test/a-first.txt");
|
||||
await runCliWithInput("file-z\n", vaultDir, "--settings", settingsFile, "put", "test/z-last.txt");
|
||||
|
||||
const lsOut = await run("ls", "test/");
|
||||
const lines = lsOut.trim().split("\n").filter(Boolean);
|
||||
assert(lines.length >= 3, "ls prefix output expected at least 3 rows");
|
||||
|
||||
// Verify sorted ascending by path
|
||||
const paths = lines.map((l) => l.split("\t")[0]);
|
||||
for (let i = 1; i < paths.length; i++) {
|
||||
assert(paths[i - 1] <= paths[i], `ls output not sorted: ${paths[i - 1]} > ${paths[i]}`);
|
||||
}
|
||||
assert(
|
||||
lines.some((l) => l.startsWith("test/a-first.txt\t")),
|
||||
"ls prefix output missing test/a-first.txt"
|
||||
);
|
||||
assert(
|
||||
lines.some((l) => l.startsWith("test/z-last.txt\t")),
|
||||
"ls prefix output missing test/z-last.txt"
|
||||
);
|
||||
console.log("[PASS] ls prefix and sorting matched");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ls: no-match prefix returns empty output
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("ls no-match prefix returns empty", async () => {
|
||||
const lsOut = await run("ls", "no-such-prefix/");
|
||||
assert.deepStrictEqual(lsOut.trim(), "", "ls no-match prefix should produce empty output");
|
||||
console.log("[PASS] ls no-match prefix matched");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// info: JSON output format
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("info output JSON format", async () => {
|
||||
const infoOut = await run("info", REMOTE_PATH);
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(infoOut);
|
||||
} catch {
|
||||
throw new Error(`info output is not valid JSON:\n${infoOut}`);
|
||||
}
|
||||
assert.deepStrictEqual(data.path, REMOTE_PATH, "info .path mismatch");
|
||||
assert.deepStrictEqual(data.filename, REMOTE_PATH.split("/").at(-1), "info .filename mismatch");
|
||||
assert(typeof data.size === "number" && data.size >= 0, `info .size invalid: ${data.size}`);
|
||||
assert(typeof data.chunks === "number" && data.chunks >= 1, `info .chunks invalid: ${data.chunks}`);
|
||||
assert.deepStrictEqual(data.conflicts, "N/A", "info .conflicts should be N/A");
|
||||
console.log("[PASS] info output format matched");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// info: non-existent path exits non-zero
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("info non-existent path returns non-zero", async () => {
|
||||
const r = await runCli(vaultDir, "--settings", settingsFile, "info", "no-such-file.md");
|
||||
assert(r.code !== 0, "info on non-existent file should exit non-zero");
|
||||
console.log("[PASS] info non-existent path returns non-zero");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// rm: removes file from ls and makes cat fail
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("rm removes target from ls and cat", async () => {
|
||||
await run("rm", "test/z-last.txt");
|
||||
|
||||
const catResult = await runCli(vaultDir, "--settings", settingsFile, "cat", "test/z-last.txt");
|
||||
assert(catResult.code !== 0, "rm target should not be readable by cat");
|
||||
|
||||
const lsOut = await run("ls", "test/");
|
||||
assert(!lsOut.includes("test/z-last.txt\t"), "rm target should not appear in ls output");
|
||||
console.log("[PASS] rm removed target from visible entries");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// resolve: accepts current revision, rejects invalid revision
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("resolve: valid and invalid revisions", async () => {
|
||||
const lsLine = (await run("ls", "test/a-first.txt")).trim().split("\n")[0];
|
||||
assert(lsLine, "could not fetch revision for resolve test");
|
||||
const rev = lsLine.split("\t")[3];
|
||||
assert(rev?.length > 0, "revision was empty for resolve test");
|
||||
|
||||
await run("resolve", "test/a-first.txt", rev);
|
||||
console.log("[PASS] resolve accepted current revision");
|
||||
|
||||
const badR = await runCli(vaultDir, "--settings", settingsFile, "resolve", "test/a-first.txt", "9-no-such-rev");
|
||||
assert(badR.code !== 0, "resolve with non-existent revision should exit non-zero");
|
||||
console.log("[PASS] resolve non-existent revision returns non-zero");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// cat-rev / pull-rev: retrieve a past revision
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("cat-rev / pull-rev: retrieve past revision", async () => {
|
||||
const revPath = "test/revision-history.txt";
|
||||
await runCliWithInput("revision-v1\n", vaultDir, "--settings", settingsFile, "put", revPath);
|
||||
await runCliWithInput("revision-v2\n", vaultDir, "--settings", settingsFile, "put", revPath);
|
||||
await runCliWithInput("revision-v3\n", vaultDir, "--settings", settingsFile, "put", revPath);
|
||||
|
||||
const infoOut = await run("info", revPath);
|
||||
const infoData = JSON.parse(infoOut) as {
|
||||
revisions?: string[];
|
||||
};
|
||||
const revisions = Array.isArray(infoData.revisions) ? infoData.revisions : [];
|
||||
const pastRev = revisions.find((r): r is string => typeof r === "string" && r !== "N/A");
|
||||
assert(pastRev, "info output did not include any past revision");
|
||||
|
||||
const catRevOut = await run("cat-rev", revPath, pastRev);
|
||||
const catRevClean = sanitiseCatStdout(catRevOut);
|
||||
assert(
|
||||
catRevClean === "revision-v1\n" || catRevClean === "revision-v2\n",
|
||||
`cat-rev output did not match expected past revision:\n${catRevClean}`
|
||||
);
|
||||
console.log("[PASS] cat-rev matched one of the past revisions from info");
|
||||
|
||||
const pullRevFile = workDir.join("rev-pull-output.txt");
|
||||
await run("pull-rev", revPath, pullRevFile, pastRev);
|
||||
const pullRevContent = await readFile(pullRevFile, "utf8");
|
||||
assert(
|
||||
pullRevContent === "revision-v1\n" || pullRevContent === "revision-v2\n",
|
||||
`pull-rev output did not match expected past revision:\n${pullRevContent}`
|
||||
);
|
||||
console.log("[PASS] pull-rev matched one of the past revisions from info");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Node.js port of test-sync-locked-remote-linux.sh
|
||||
*
|
||||
* Verifies CLI sync behaviour when the remote milestone document is unlocked
|
||||
* versus locked.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
||||
|
||||
function assertStringIncludes(actual: string, expected: string, message?: string): void {
|
||||
assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`);
|
||||
}
|
||||
|
||||
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
||||
|
||||
function requireEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
test("sync: actionable error against locked remote DB", async () => {
|
||||
const couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "");
|
||||
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
||||
const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password");
|
||||
const dbPrefix = requireEnv("COUCHDB_DBNAME", "dbname");
|
||||
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
||||
const vaultDir = workDir.join("vault");
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
await mkdir(vaultDir, { recursive: true });
|
||||
|
||||
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
|
||||
if (shouldStartDocker) {
|
||||
console.log(`[INFO] starting CouchDB and creating test database: ${dbname}`);
|
||||
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||
} else {
|
||||
console.log(`[INFO] using existing CouchDB and creating test database: ${dbname}`);
|
||||
await createCouchdbDatabase(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||
}
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsFile);
|
||||
await applyCouchdbSettings(settingsFile, couchdbUri, couchdbUser, couchdbPassword, dbname, true);
|
||||
|
||||
console.log("[CASE] initial sync to create milestone document");
|
||||
const initialSync = await runCli(vaultDir, "--settings", settingsFile, "sync");
|
||||
assert(
|
||||
initialSync.code === 0,
|
||||
`initial sync failed\nstdout: ${initialSync.stdout}\nstderr: ${initialSync.stderr}`
|
||||
);
|
||||
|
||||
const updateMilestone = async (locked: boolean) => {
|
||||
await updateCouchdbDoc(couchdbUri, couchdbUser, couchdbPassword, `${dbname}/${MILESTONE_DOC}`, (doc) => ({
|
||||
...doc,
|
||||
locked,
|
||||
accepted_nodes: [],
|
||||
}));
|
||||
};
|
||||
|
||||
console.log("[CASE] sync should succeed when remote is not locked");
|
||||
await updateMilestone(false);
|
||||
const unlockedSync = await runCli(vaultDir, "--settings", settingsFile, "sync");
|
||||
assert(
|
||||
unlockedSync.code === 0,
|
||||
`sync should succeed when remote is not locked\nstdout: ${unlockedSync.stdout}\nstderr: ${unlockedSync.stderr}`
|
||||
);
|
||||
assert(
|
||||
!unlockedSync.combined.includes("The remote database is locked"),
|
||||
`locked error should not appear when remote is not locked\n${unlockedSync.combined}`
|
||||
);
|
||||
console.log("[PASS] unlocked remote DB syncs successfully");
|
||||
|
||||
console.log("[CASE] sync should fail with actionable error when remote is locked");
|
||||
await updateMilestone(true);
|
||||
const lockedSync = await runCli(vaultDir, "--settings", settingsFile, "sync");
|
||||
assert(
|
||||
lockedSync.code !== 0,
|
||||
`sync should fail when remote is locked\nstdout: ${lockedSync.stdout}\nstderr: ${lockedSync.stderr}`
|
||||
);
|
||||
assertStringIncludes(lockedSync.combined, "The remote database is locked and this device is not yet accepted");
|
||||
console.log("[PASS] locked remote DB produces actionable CLI error");
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Node.js port of test-sync-two-local-databases-linux.sh
|
||||
*
|
||||
* Tests two-vault synchronisation via CouchDB including conflict detection
|
||||
* and resolution.
|
||||
*
|
||||
* Requires CouchDB connection details. Provide them via environment variables
|
||||
* OR place a .test.env file at src/apps/cli/.test.env.
|
||||
*
|
||||
* By default, a CouchDB Docker container is started automatically
|
||||
* (LIVESYNC_START_DOCKER=1). Set LIVESYNC_START_DOCKER=0 to use an existing
|
||||
* CouchDB instance instead.
|
||||
*
|
||||
* Run:
|
||||
* node --test test-sync-two-local-databases.ts
|
||||
*
|
||||
* With an existing CouchDB:
|
||||
* COUCHDB_URI=http://127.0.0.1:5984 \
|
||||
* COUCHDB_USER=admin \
|
||||
* COUCHDB_PASSWORD=password \
|
||||
* COUCHDB_DBNAME=livesync-test \
|
||||
* LIVESYNC_START_DOCKER=0 \
|
||||
* node --test test-sync-two-local-databases.ts
|
||||
*/
|
||||
|
||||
import { test, type TestContext } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveConfig(): Promise<{
|
||||
uri: string;
|
||||
user: string;
|
||||
password: string;
|
||||
baseDbname: string;
|
||||
} | null> {
|
||||
const env = { ...process.env };
|
||||
|
||||
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
||||
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
||||
const password = env["COUCHDB_PASSWORD"] ?? env["password"] ?? "";
|
||||
const baseDbname = env["COUCHDB_DBNAME"] ?? env["dbname"] ?? "livesync-test";
|
||||
|
||||
if (!uri || !user || !password) return null;
|
||||
return { uri, user, password, baseDbname };
|
||||
}
|
||||
|
||||
const config = await resolveConfig();
|
||||
const START_DOCKER = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||
const KEEP_DOCKER = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
const SYNC_RETRY = Number(process.env["LIVESYNC_SYNC_RETRY"] ?? "8");
|
||||
|
||||
// Provide a sane default for flaky remote connectivity in Docker-on-WSL
|
||||
// environments. Users can override explicitly if needed.
|
||||
if (!Object.hasOwn(process.env, "LIVESYNC_CLI_RETRY")) {
|
||||
(process.env["LIVESYNC_CLI_RETRY"] = "2");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
"sync two local databases: sync + conflict detection + resolution",
|
||||
{ skip: config === null },
|
||||
async (t) => {
|
||||
if (!config) return; // narrowing for TypeScript
|
||||
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 65535)}`;
|
||||
const dbname = `${config.baseDbname}-${suffix}`;
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-two-db-test");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Docker lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
if (START_DOCKER) {
|
||||
await startCouchdb(config.uri, config.user, config.password, dbname);
|
||||
}
|
||||
|
||||
try {
|
||||
await runSuite(t, workDir, config, dbname);
|
||||
} finally {
|
||||
if (START_DOCKER && !KEEP_DOCKER) {
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
if (START_DOCKER && KEEP_DOCKER) {
|
||||
console.log("[INFO] LIVESYNC_DEBUG_KEEP_DOCKER=1, keeping couchdb-test container");
|
||||
}
|
||||
console.log(`[INFO] test database '${dbname}' is preserved for debugging.`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runSuite(
|
||||
t: TestContext,
|
||||
workDir: TempDir,
|
||||
config: { uri: string; user: string; password: string },
|
||||
dbname: string
|
||||
): Promise<void> {
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const runWithRetry = async <T>(label: string, fn: () => Promise<T>, retries = SYNC_RETRY): Promise<T> => {
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i === retries) break;
|
||||
const delayMs = 500 * (i + 1);
|
||||
console.warn(`[WARN] ${label} failed, retrying (${i + 1}/${retries}) in ${delayMs}ms`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("a-settings.json");
|
||||
const settingsB = workDir.join("b-settings.json");
|
||||
await mkdir(vaultA, { recursive: true });
|
||||
await mkdir(vaultB, { recursive: true });
|
||||
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
|
||||
const applySettings = async (f: string) =>
|
||||
applyCouchdbSettings(f, config.uri, config.user, config.password, dbname, /* liveSync */ true);
|
||||
await applySettings(settingsA);
|
||||
await applySettings(settingsB);
|
||||
|
||||
const runA = (...args: string[]) => runCliOrFail(vaultA, "--settings", settingsA, ...args);
|
||||
const runB = (...args: string[]) => runCliOrFail(vaultB, "--settings", settingsB, ...args);
|
||||
|
||||
const syncA = () => runWithRetry("syncA", () => runA("sync"));
|
||||
const syncB = () => runWithRetry("syncB", () => runB("sync"));
|
||||
const catA = (path: string) => runA("cat", path);
|
||||
const catB = (path: string) => runB("cat", path);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Case 1: A creates file, B reads after sync
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("case 1: A creates file -> B can read after sync", async () => {
|
||||
const srcA = workDir.join("from-a-src.txt");
|
||||
await writeFile(srcA, "from-a\n");
|
||||
await runA("push", srcA, "shared/from-a.txt");
|
||||
await syncA();
|
||||
await syncB();
|
||||
const value = (await catB("shared/from-a.txt")).replace(/\r\n/g, "\n").trimEnd();
|
||||
assert.deepStrictEqual(value, "from-a", "B could not read file created on A");
|
||||
console.log("[PASS] case 1 passed");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Case 2: B creates file, A reads after sync
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("case 2: B creates file -> A can read after sync", async () => {
|
||||
const srcB = workDir.join("from-b-src.txt");
|
||||
await writeFile(srcB, "from-b\n");
|
||||
await runB("push", srcB, "shared/from-b.txt");
|
||||
await syncB();
|
||||
await syncA();
|
||||
const value = (await catA("shared/from-b.txt")).replace(/\r\n/g, "\n").trimEnd();
|
||||
assert.deepStrictEqual(value, "from-b", "A could not read file created on B");
|
||||
console.log("[PASS] case 2 passed");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Case 3: concurrent edits create a conflict
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("case 3: concurrent edits create conflict", async () => {
|
||||
const baseSrc = workDir.join("base-src.txt");
|
||||
await writeFile(baseSrc, "base\n");
|
||||
await runA("push", baseSrc, "shared/conflicted.txt");
|
||||
await syncA();
|
||||
await syncB();
|
||||
|
||||
const aEdit = workDir.join("edit-a.txt");
|
||||
const bEdit = workDir.join("edit-b.txt");
|
||||
await writeFile(aEdit, "edit-from-a\n");
|
||||
await writeFile(bEdit, "edit-from-b\n");
|
||||
await runA("push", aEdit, "shared/conflicted.txt");
|
||||
await runB("push", bEdit, "shared/conflicted.txt");
|
||||
|
||||
const infoFileA = workDir.join("info-a.json");
|
||||
const infoFileB = workDir.join("info-b.json");
|
||||
|
||||
let conflictDetected = false;
|
||||
for (const side of ["a", "b"] as const) {
|
||||
if (side === "a") await syncA();
|
||||
else await syncB();
|
||||
await writeFile(infoFileA, await runA("info", "shared/conflicted.txt"));
|
||||
await writeFile(infoFileB, await runB("info", "shared/conflicted.txt"));
|
||||
const da = JSON.parse(await readFile(infoFileA, "utf8")) as Record<string, unknown>;
|
||||
const db = JSON.parse(await readFile(infoFileB, "utf8")) as Record<string, unknown>;
|
||||
if (!jsonFieldIsNa(da, "conflicts") || !jsonFieldIsNa(db, "conflicts")) {
|
||||
conflictDetected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(conflictDetected, "expected conflict after concurrent edits, but both sides show N/A");
|
||||
console.log("[PASS] case 3 conflict detected");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Case 4: resolve on A, verify B has no conflict after sync
|
||||
// ------------------------------------------------------------------
|
||||
await t.test("case 4: resolve on A propagates to B", async () => {
|
||||
const infoFileA = workDir.join("info-a-resolve.json");
|
||||
const infoFileB = workDir.join("info-b-resolve.json");
|
||||
|
||||
// Ensure A sees the conflict
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const raw = await runA("info", "shared/conflicted.txt");
|
||||
await writeFile(infoFileA, raw);
|
||||
const da = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!jsonFieldIsNa(da, "conflicts")) break;
|
||||
await syncB();
|
||||
await syncA();
|
||||
}
|
||||
|
||||
const rawA = await runA("info", "shared/conflicted.txt");
|
||||
await writeFile(infoFileA, rawA);
|
||||
const dataA = JSON.parse(rawA) as Record<string, unknown>;
|
||||
assert(!jsonFieldIsNa(dataA, "conflicts"), "A does not see conflict, cannot resolve from A only");
|
||||
|
||||
const keepRev = dataA["revision"] as string;
|
||||
assert(keepRev?.length > 0, "could not read revision from A info output");
|
||||
|
||||
await runA("resolve", "shared/conflicted.txt", keepRev);
|
||||
|
||||
let resolved = false;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await syncA();
|
||||
await syncB();
|
||||
const rawA2 = await runA("info", "shared/conflicted.txt");
|
||||
const rawB2 = await runB("info", "shared/conflicted.txt");
|
||||
await writeFile(infoFileA, rawA2);
|
||||
await writeFile(infoFileB, rawB2);
|
||||
const da2 = JSON.parse(rawA2) as Record<string, unknown>;
|
||||
const db2 = JSON.parse(rawB2) as Record<string, unknown>;
|
||||
if (jsonFieldIsNa(da2, "conflicts") && jsonFieldIsNa(db2, "conflicts")) {
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
// If A still sees a conflict, resolve it again
|
||||
if (!jsonFieldIsNa(da2, "conflicts")) {
|
||||
const rev2 = da2["revision"] as string;
|
||||
if (rev2) await runA("resolve", "shared/conflicted.txt", rev2).catch(() => {});
|
||||
}
|
||||
}
|
||||
assert(resolved, "conflicts should be resolved on both A and B");
|
||||
|
||||
const contentA = (await catA("shared/conflicted.txt")).replace(/\r\n/g, "\n");
|
||||
const contentB = (await catB("shared/conflicted.txt")).replace(/\r\n/g, "\n");
|
||||
assert.deepStrictEqual(contentA, contentB, "resolved content mismatch between A and B");
|
||||
console.log("[PASS] case 4 passed");
|
||||
console.log("[PASS] all sync/resolve scenarios passed");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# CLI Node.js Test Development Notes
|
||||
|
||||
The CLI test suite under `test/e2e-cli/` uses Node.js 26 native TypeScript execution and the built-in `node:test` runner. The standard service-backed suite is selected by `ci-tasks.json`. P2P scenarios use the repository Compose entry point so that networking, signalling, and the runner environment are reproducible. Existing Bash tests under `src/apps/cli/test/` remain available as compatibility references.
|
||||
|
||||
## Goals and structure
|
||||
|
||||
- Run CLI subprocess and filesystem scenarios on Windows, macOS, and Linux.
|
||||
- Use Node.js standard modules and shared helpers in place of a separate test runtime.
|
||||
- Keep the default service-backed suite and canonical Compose P2P suite distinct.
|
||||
|
||||
```text
|
||||
test/e2e-cli/
|
||||
tasks.json
|
||||
ci-tasks.json
|
||||
run-task.ts
|
||||
run-ci-suite.ts
|
||||
run-compose-p2p.ts
|
||||
run-coverage.ts
|
||||
helpers/
|
||||
test-*.ts
|
||||
```
|
||||
|
||||
Node executes erasable TypeScript syntax directly. The scoped `test/e2e-cli/tsconfig.json` checks that test files stay within that runtime boundary. The task runner uses one test worker at a time and loads the CLI package `.test.env` file when present.
|
||||
|
||||
## Task manifest
|
||||
|
||||
`tasks.json` preserves named test and benchmark entry points as structured records. A record can name test files and an optional test-name filter, a TypeScript script, an ordered list of other tasks, or a command with arguments for the retained benchmark shell scripts. Shell commands are restricted to those legacy benchmark tasks.
|
||||
|
||||
`ci-tasks.json` is the source of truth for the default service-backed CI task list. Keep it aligned with the `test:ci` task runner. `test:local` groups setup, mirror, and daemon smoke tasks which do not require Docker.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `test-setup-put-cat.ts` covers setup URI generation and core CLI commands without a remote service.
|
||||
- `test-mirror.ts` covers seven mirror scenarios, including unknown revision provenance, conflict preservation, deduplication, and resolution.
|
||||
- `test-daemon.ts` and `test-daemon-startup.ts` cover daemon ignore rules and startup behaviour.
|
||||
- `test-push-pull.ts`, `test-sync-two-local-databases.ts`, and `test-sync-locked-remote.ts` cover remote synchronisation and locking.
|
||||
- `test-decoupled-vault.ts` covers a vault directory which is separate from the database directory.
|
||||
- `test-remote-commands.ts`, `test-remote-administration-exit-codes.ts`, and `test-settings-writeback.ts` cover remote management and settings behaviour.
|
||||
- P2P tests cover host startup, peer discovery, synchronisation, automatic host policy, replacement, relay disconnection, multi-node conflicts, and upload/download. The Compose suite runs the canonical synchronisation, replacement, and relay-disconnection scenarios.
|
||||
- `test-e2e-two-vaults-couchdb.ts` and `test-e2e-two-vaults-matrix.ts` cover two-vault CouchDB and CouchDB/Object Storage cases.
|
||||
|
||||
## Running tests
|
||||
|
||||
Run the canonical workflows from the repository root. These npm scripts build the CLI before the suite where needed:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:cli
|
||||
npm run test:e2e:cli:p2p
|
||||
npm run test:e2e:cli:all
|
||||
```
|
||||
|
||||
Run an individual named task after building the CLI:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:task --workspace self-hosted-livesync-cli -- test:mirror
|
||||
```
|
||||
|
||||
The local smoke set is also available as `test:local`. It runs setup, mirror, and daemon tests without starting Docker services. To run the mirror file directly from its directory:
|
||||
|
||||
```bash
|
||||
cd test/e2e-cli
|
||||
node --env-file-if-exists=../../src/apps/cli/.test.env --test --test-concurrency=1 test-mirror.ts
|
||||
```
|
||||
|
||||
Collect CLI body coverage with the local smoke suite, or pass `test:ci` to cover the standard service-backed suite:
|
||||
|
||||
```bash
|
||||
npm run test:e2e:coverage --workspace self-hosted-livesync-cli
|
||||
npm run test:e2e:coverage --workspace self-hosted-livesync-cli -- test:ci
|
||||
```
|
||||
|
||||
The runner defaults to `test:local`, which does not require services. It writes HTML, JSON, and text reports under `coverage/cli-e2e/`. The runner enables CLI bundle source maps only for the coverage build with `LIVESYNC_CLI_COVERAGE=1`; ordinary builds do not enable them.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### CouchDB
|
||||
|
||||
- `COUCHDB_URI`
|
||||
- `COUCHDB_USER`
|
||||
- `COUCHDB_PASSWORD`
|
||||
- `COUCHDB_DBNAME`
|
||||
|
||||
The equivalent keys in `.test.env` are `hostname`, `username`, `password`, and `dbname`.
|
||||
|
||||
### Behaviour switches
|
||||
|
||||
- `LIVESYNC_START_DOCKER=0`: use an existing CouchDB service.
|
||||
- `REMOTE_PATH`: override the target path for selected tests.
|
||||
- `LIVESYNC_TEST_TEE=1`: stream CLI stdout and stderr during execution.
|
||||
- `LIVESYNC_DOCKER_TEE=1`: stream Docker stdout and stderr.
|
||||
- `LIVESYNC_CLI_RETRY=<n>`: retry transient network failures.
|
||||
- `LIVESYNC_DEBUG_KEEP_DOCKER=1`: keep `couchdb-test` after a test completes.
|
||||
|
||||
### Docker command selection
|
||||
|
||||
`helpers/docker.ts` supports `LIVESYNC_DOCKER_MODE=auto` by default. On Windows it tries `wsl docker` first, then `docker`; on other platforms it tries `docker` first, then `wsl docker`. Set `LIVESYNC_DOCKER_MODE=native` or `LIVESYNC_DOCKER_MODE=wsl` to select one explicitly. `LIVESYNC_DOCKER_COMMAND` takes priority and can specify a custom command, such as `wsl docker`.
|
||||
|
||||
### P2P
|
||||
|
||||
- `RELAY`
|
||||
- `ROOM_ID`
|
||||
- `PASSPHRASE`
|
||||
- `APP_ID`
|
||||
- `PEERS_TIMEOUT`
|
||||
- `SYNC_TIMEOUT`
|
||||
- `USE_INTERNAL_RELAY=0|1`
|
||||
- `TIMEOUT_SECONDS`
|
||||
|
||||
## Continuous integration
|
||||
|
||||
The `.github/workflows/cli-node-tests.yml` workflow runs for changes to the CLI or its shared runtime inputs. Its Linux `prepare` job builds the CLI once and distributes that bundle to the Linux service-backed test jobs. The `local-smoke` job runs automatically on pushes and non-draft pull requests, and also when `test:local` is selected manually; it builds the CLI once on each of Ubuntu, macOS, and Windows and starts no Docker services.
|
||||
|
||||
The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite for CLI and `test/bench-network/` changes. A change confined to Compose fixtures does not start the non-P2P matrix.
|
||||
|
||||
The `.github/workflows/cli-docker-validation.yml` workflow still builds the production CLI bundle with Node.js 22, then validates Docker images on native amd64 and arm64 runners. That release-image runtime remains independent of the Node.js version used by CLI E2E tests.
|
||||
|
||||
## Maintenance policy
|
||||
|
||||
Keep scenarios in Node test files and reuse helpers from `helpers/`. Preserve the authoritative CI task list in `ci-tasks.json`. Retain the Bash suites and benchmark wrappers until their corresponding migration has been separately agreed and completed.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user