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:
vorotamoroz committed 2026-10-31 06:01:30 +00:00
1 parent a0987016bb
commit 2433d117c2
91 files changed
+2219 -1615

No files matched your search

+6 -4
View File
@@ -20,19 +20,21 @@
"test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh",
"test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh",
"pretest:e2e:ci": "npm run build",
"test:e2e:ci": "deno task --cwd testdeno test:ci",
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
"test:e2e:ci": "node ../../../test/e2e-cli/run-ci-suite.ts",
"test:e2e:p2p": "node ../../../test/e2e-cli/run-compose-p2p.ts",
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
"pretest:e2e:all": "npm run build",
"test:e2e:all": "deno task --cwd testdeno test:ci && deno task --cwd testdeno test:p2p:compose",
"test:e2e:all": "node ../../../test/e2e-cli/run-ci-suite.ts && node ../../../test/e2e-cli/run-compose-p2p.ts",
"pretest:e2e:docker:all": "npm run build:docker",
"test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh",
"test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh",
"test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh",
"test:e2e:docker:remote-commands": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-remote-commands-linux.sh",
"test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh",
"test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror && npm run test:e2e:docker:remote-commands"
"test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror && npm run test:e2e:docker:remote-commands",
"test:e2e:task": "node ../../../test/e2e-cli/run-task.ts",
"test:e2e:coverage": "node ../../../test/e2e-cli/run-coverage.ts"
},
"dependencies": {
"chokidar": "^4.0.0",
-9
View File
@@ -1,9 +0,0 @@
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
+2 -150
View File
@@ -1,151 +1,3 @@
# Writing CLI Tests on Deno
# Moved
This guide explains how to add or update tests under `src/apps/cli/testdeno/`.
Note that new tests should be added to the Deno suite rather than the existing bash suite due to the cross-platform execution and TypeScript benefits.
## Scope
The Deno suite is designed for cross-platform execution, with a strong focus on Windows compatibility while keeping behaviour equivalent to existing bash tests.
## 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.
## Directory structure
```
src/apps/cli/testdeno/
helpers/
backgroundCli.ts
cli.ts
docker.ts
env.ts
p2p.ts
settings.ts
temp.ts
test-*.ts
deno.json
```
## Test file naming
- Use `test-<feature>.ts`.
- Use names aligned with existing bash tests when porting, for example:
- `test-sync-locked-remote.ts`
- `test-p2p-sync.ts`
## Core helper usage
### Temporary workspace
Use `TempDir` and `await using` so cleanup is automatic:
```ts
await using workDir = await TempDir.create("livesync-cli-my-test");
```
### CLI execution
- `runCli(...)`: returns code and combined output.
- `runCliOrFail(...)`: throws on non-zero exit.
- `runCliWithInputOrFail(input, ...)`: for `put` and stdin-driven commands.
### Settings
- `initSettingsFile(...)`: creates a baseline settings file.
- `applyCouchdbSettings(...)`: applies CouchDB fields.
- `applyRemoteSyncSettings(...)`: applies remote and encryption fields.
- `applyP2pSettings(...)`: applies P2P fields.
- `applyP2pTestTweaks(...)`: enables P2P-only test profile.
### Docker services
- `startCouchdb(...)`, `stopCouchdb()`
- `startP2pRelay()`, `stopP2pRelay()`
### P2P discovery
- `discoverPeer(...)`
- `maybeStartLocalRelay(...)`
- `stopLocalRelayIfStarted(...)`
### Background host process
Use `startCliInBackground(...)` for long-running host mode such as `p2p-host`.
## Recommended test structure
1. Arrange
2. Act
3. Assert
4. Cleanup in `finally`
Example skeleton:
```ts
Deno.test("feature: behaviour", async () => {
await using workDir = await TempDir.create("example");
// Arrange
try {
// Act
// Assert
} finally {
// Optional explicit cleanup
}
});
```
## Reliability guidelines
- Use explicit waits only when needed for eventual consistency.
- Re-run sync operations where the protocol is eventually consistent.
- For network-sensitive commands, use `LIVESYNC_CLI_RETRY` during debugging.
- Keep Docker container reuse disabled by default unless debugging.
- Use `npm run test:e2e:cli:p2p` for canonical P2P validation. It runs the Deno scenario in Compose because host networking and WebRTC candidate selection are not reproducible across environments. Individual `deno task test:p2p-*` tasks remain available when explicitly invoked for cross-platform diagnostics, but are not selected by the default suite or CI.
## Environment variables
Common variables:
- `LIVESYNC_DOCKER_MODE`
- `LIVESYNC_DOCKER_COMMAND`
- `LIVESYNC_TEST_TEE`
- `LIVESYNC_DOCKER_TEE`
- `LIVESYNC_CLI_DEBUG`
- `LIVESYNC_CLI_VERBOSE`
- `LIVESYNC_CLI_RETRY`
- `LIVESYNC_DEBUG_KEEP_DOCKER`
P2P variables:
- `RELAY`
- `ROOM_ID`
- `PASSPHRASE`
- `APP_ID`
- `PEERS_TIMEOUT`
- `SYNC_TIMEOUT`
- `USE_INTERNAL_RELAY`
## Adding a new test task
1. Add the test file under `src/apps/cli/testdeno/`.
2. Add a task in `src/apps/cli/testdeno/deno.json`.
3. Update `src/apps/cli/testdeno/test_dev_deno.md`.
4. Run the new task locally.
## Validation checklist
- The test passes on a clean workspace.
- The test does not leave persistent artefacts unless explicitly requested.
- Failure messages identify both expected and actual behaviour.
- The corresponding task is documented.
## Out of scope for this suite
- One-off reproduction scripts that are not intended as stable regression tests.
The CLI test authoring guide is now [test/e2e-cli/CONTRIBUTING_TESTS.md](../../../../test/e2e-cli/CONTRIBUTING_TESTS.md).
-603
View File
@@ -1,603 +0,0 @@
import { join } from "@std/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 = Deno.env.get(name)?.trim();
return value ? value : fallback;
}
function readEnvPositiveInteger(name: string, fallback: number): number {
const raw = Deno.env.get(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 = Deno.env.get(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 = Deno.env.get(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 =
Deno.env.get("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 Deno.mkdir(resultRoot, { recursive: true });
const proxy = 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 Deno.mkdir(vaultA, { recursive: true });
await Deno.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 Deno.readTextFile(settingsA)) 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 Deno.readTextFile(
join(import.meta.dirname!, "../../../../node_modules/@vrtmrz/livesync-commonlib/package.json")
)
) 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 Deno.writeTextFile(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);
Deno.exit(1);
});
}
-482
View File
@@ -1,482 +0,0 @@
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 = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(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 = Deno.env.get(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 = Deno.env.get(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(
Deno.env.get("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 = Deno.env.get("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 function startCouchdbProxy(
options: {
backendUri: string;
proxyUri: string;
requestedRttMs: number;
delay?: (milliseconds: number) => Promise<void>;
},
): 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 controller = new AbortController();
const counters: CouchdbProxyCounters = {
requestCount: 0,
requestBodyBytes: 0,
responseBodyBytes: 0,
};
const listener = Deno.serve(
{
hostname: proxy.hostname,
port: Number(proxy.port),
signal: controller.signal,
onError(error) {
console.error(`[Proxy] ${String(error)}`);
return new Response("proxy error", { status: 502 });
},
},
async (request) => {
await delay(halfDelayMs);
const targetUrl = new URL(request.url);
targetUrl.protocol = backend.protocol;
targetUrl.host = backend.host;
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("content-length");
let requestBody: ArrayBuffer | undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
try {
requestBody = await request.arrayBuffer();
} catch {
requestBody = undefined;
}
}
counters.requestCount += 1;
counters.requestBodyBytes += requestBody?.byteLength ?? 0;
const upstream = await fetch(targetUrl, {
method: request.method,
headers,
body: 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);
return new Response(responseBody, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
},
);
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 () => {
controller.abort();
await listener.finished.catch(() => {});
},
};
}
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 Deno.mkdir(vaultA, { recursive: true });
await Deno.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 = 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 Deno.writeTextFile(
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);
Deno.exit(1);
});
}
@@ -1,175 +0,0 @@
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 = Deno.env.get(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 = `${options.outputDir}/${options.name}${suffix}.json`;
const env = {
...Deno.env.toObject(),
...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 = new Deno.Command("deno", {
args: ["task", options.taskName],
cwd: import.meta.dirname,
env,
stdin: "null",
stdout: "inherit",
stderr: "inherit",
}).spawn();
const status = await child.status;
if (status.code !== 0) {
throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`);
}
return JSON.parse(await Deno.readTextFile(resultPath)) as Record<string, unknown>;
}
async function main(): Promise<void> {
const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`);
const outputDir = `${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 Deno.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 Deno.writeTextFile(`${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);
Deno.exit(1);
});
}
@@ -1,447 +0,0 @@
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 = Deno.env.get(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 = `${outputDir}/${testCase.name}${suffix}.json`;
const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
const env = {
...Deno.env.toObject(),
...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 command = new Deno.Command("deno", {
args: ["task", taskName],
cwd: import.meta.dirname,
env,
stdin: "null",
stdout: "inherit",
stderr: "inherit",
});
const child = command.spawn();
const status = await child.status;
if (status.code !== 0) {
throw new Error(`case failed: ${testCase.name} (exit ${status.code})`);
}
const result = JSON.parse(await Deno.readTextFile(resultPath)) 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",
`${import.meta.dirname}/bench-results`,
);
const outputDir = `${outRoot}/cases-${timestamp()}`;
await Deno.mkdir(outputDir, { recursive: true });
const allCases = buildCases();
const cases = selectCases(allCases);
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
await Deno.writeTextFile(
`${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 Deno.writeTextFile(
`${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);
Deno.exit(1);
});
}
@@ -1,435 +0,0 @@
import { join } from "@std/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";
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 = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(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 new Deno.Command(command, {
args,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
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 new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output();
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 stat = await Deno.stat(path);
if (stat.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 Deno.readTextFile(path)) 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 Deno.readTextFile(path))
.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/src/apps/cli/testdeno/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 Deno.mkdir(config.workRoot, { recursive: true });
await Deno.mkdir(config.resultRoot, { recursive: true });
const hostVault = join(config.workRoot, "vault-host");
const hostSettings = join(config.workRoot, "settings-host.json");
await Deno.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 Deno.writeTextFile(
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 Deno.writeTextFile(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 Deno.writeTextFile(
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 Deno.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 Deno.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 Deno.mkdir(outputDir, { recursive: true });
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
Deno.env.set("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: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
networkProfile: config.profile,
networkModel:
Deno.env.get("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 Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
await Deno.writeTextFile(
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: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1",
networkProfile: config.profile,
networkModel:
Deno.env.get("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 Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
await Deno.writeTextFile(
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) {
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
} else {
Deno.env.set("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);
Deno.exit(1);
});
}
-445
View File
@@ -1,445 +0,0 @@
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 = Deno.env.get(name)?.trim();
return value && value.length > 0 ? value : fallback;
}
function readEnvNumber(name: string, fallback: number): number {
const raw = Deno.env.get(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 = Deno.env.get(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(
Deno.env.get("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 = Deno.env.get("BENCH_RESULT_JSON")?.trim();
if (!raw) {
return undefined;
}
return raw;
}
async function readLatestP2PConnectionStats(
statsPath: string,
): Promise<P2PConnectionStats | undefined> {
try {
const text = await Deno.readTextFile(statsPath);
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 = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath);
try {
await Promise.all([
Deno.mkdir(hostVault, { recursive: true }),
Deno.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 Deno.writeTextFile(
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) {
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
} else {
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
}
await stopCoturnIfStarted(coturnStarted);
await stopLocalRelayIfStarted(relayStarted);
}
}
if (import.meta.main) {
main().catch((error) => {
console.error(`[Fatal Error]`, error);
Deno.exit(1);
});
}
-45
View File
@@ -1,45 +0,0 @@
#!/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" deno task 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" deno task 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}"
-17
View File
@@ -1,17 +0,0 @@
[
"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"
]
-47
View File
@@ -1,47 +0,0 @@
{
"tasks": {
"test": "deno task test:ci",
"test:ci": "deno run -A --no-check run-ci-suite.ts",
"test:p2p:compose": "deno run -A --no-check run-compose-p2p.ts",
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts test-daemon.ts",
"test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts",
"test:daemon-startup": "deno test --env-file=.test.env -A --no-check test-daemon-startup.ts",
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
"test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts",
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
"test:sync-two-local": "deno test --env-file=.test.env -A --no-check test-sync-two-local-databases.ts",
"test:sync-locked-remote": "deno test --env-file=.test.env -A --no-check test-sync-locked-remote.ts",
"test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts",
"test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts",
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
"test:p2p-automatic-host": "deno test --env-file=.test.env -A --no-check test-p2p-automatic-host.ts",
"test:p2p-replacement": "deno test --env-file=.test.env -A --no-check test-p2p-replicator-replacement.ts",
"test:p2p-relay-disconnect": "deno test --env-file=.test.env -A --no-check test-p2p-relay-disconnect.ts",
"test:p2p:ci": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts test-p2p-automatic-host.ts test-p2p-replicator-replacement.ts test-p2p-relay-disconnect.ts",
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
"test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts",
"bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts",
"bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts",
"bench:compression": "deno run --env-file=.test.env -A --no-check bench-compression.ts",
"bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts",
"bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts",
"bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts",
"bench:item1": "bash ./bench-run-item1.sh",
"bench:item1:full": "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": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
"test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.13",
"@std/path": "jsr:@std/path@^1.0.9"
}
}
-31
View File
@@ -1,31 +0,0 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.13": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.0.9": "1.1.4"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.13",
"jsr:@std/path@^1.0.9"
]
}
}
@@ -1,120 +0,0 @@
import { CLI_DIR, TEE_ENABLED, formatTeeCommand, createLineTeeWriter } from "./cli.ts";
import { join } from "@std/path";
const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
const DEBUG_ENABLED = Deno.env.get("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>;
constructor(
readonly child: Deno.ChildProcess,
readonly args: string[]
) {
const cliArgs = decorateArgs(args);
this.#stdoutDone = pump(
child.stdout,
(text) => {
this.#stdout += text;
},
TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null
);
this.#stderrDone = pump(
child.stderr,
(text) => {
this.#stderr += text;
},
TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(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 = new Deno.Command("node", {
args: [CLI_DIST, ...cliArgs],
cwd: CLI_DIR,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
if (TEE_ENABLED) {
Deno.stdout.writeSync(
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`)
);
}
return new BackgroundCliProcess(child, args);
}
@@ -1,80 +0,0 @@
import type { DatasetEntry } from "./dataset.ts";
export type BenchmarkVerificationMode = "all" | "sample";
export type BenchmarkVerificationResult = {
verificationMode: BenchmarkVerificationMode;
verifiedFiles: number;
verificationComplete: boolean;
datasetDigestSha256: string;
};
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
async function sha256(bytes: Uint8Array): Promise<string> {
const input = new ArrayBuffer(bytes.byteLength);
new Uint8Array(input).set(bytes);
return toHex(await crypto.subtle.digest("SHA-256", input));
}
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 = await sha256(await Deno.readFile(entry.absolutePath));
manifest.push(
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
);
}
return await 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),
};
}
-289
View File
@@ -1,289 +0,0 @@
import { join } from "@std/path";
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
// This file lives at: src/apps/cli/testdeno/helpers/cli.ts
// CLI root (src/apps/cli/) is two levels up.
// import.meta.dirname is available in Deno 1.40+ as an OS-native path string.
export const CLI_DIR: string = join(import.meta.dirname!, "..", "..");
export const CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export interface CliResult {
stdout: string;
stderr: string;
/** stdout + stderr concatenated — useful for assertion messages. */
combined: string;
code: number;
}
export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
const DEBUG_ENABLED = Deno.env.get("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 ["node", 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 = new Deno.Command("node", {
args: [CLI_DIST, ...cliArgs],
cwd: CLI_DIR,
stdin: stdinData ? "piped" : "null",
stdout: "piped",
stderr: "piped",
}).spawn();
if (TEE_ENABLED) {
Deno.stdout.writeSync(
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`)
);
}
const stdoutPromise = collectStream(
child.stdout,
TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null
);
const stderrPromise = collectStream(
child.stderr,
TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(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(Deno.env.get("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(Deno.env.get("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([Deno.readFile(expectedPath), Deno.readFile(actualPath)]);
if (expected.length !== actual.length || expected.some((b, i) => b !== actual[i])) {
const hex = async (d: Uint8Array<ArrayBuffer>) => {
const h = await crypto.subtle.digest("SHA-256", d);
return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join("");
};
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 Deno.readTextFile(filePath)) 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";
}
@@ -1,192 +0,0 @@
import { join } from "@std/path";
import type { DatasetEntry, DatasetKind } from "./dataset.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"];
const REPOSITORY_ROOT = join(import.meta.dirname!, "..", "..", "..", "..", "..");
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> {
const command = new Deno.Command("cjpeg", {
args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm],
stdin: "null",
stdout: "piped",
stderr: "piped",
});
let result: Deno.CommandOutput;
try {
result = await command.output();
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
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 Deno.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 Deno.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 Deno.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 Deno.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 Deno.readFile(join(repositoryRoot, "docs/settings.md"));
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
await Deno.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 ppmPath = await Deno.makeTempFile({ dir: options.rootDir, prefix: "compression-jpeg-", suffix: ".ppm" });
const jpegPath = join(datasetRoot, "jpg", name);
try {
await Deno.writeFile(ppmPath, createSyntheticPpm(640, 480, `${seed}-${name}`, textured));
jpegGenerator = await jpegEncoder(ppmPath, jpegPath);
} finally {
await Deno.remove(ppmPath).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,
};
}
-125
View File
@@ -1,125 +0,0 @@
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;
}
function toPath(rootDir: string, relativePath: string): string {
return `${rootDir}/${relativePath}`;
}
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 = toPath(config.rootDir, config.datasetDirName);
const mdDir = `${datasetRoot}/md`;
const binDir = `${datasetRoot}/bin`;
await Deno.mkdir(mdDir, { recursive: true });
await Deno.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 = toPath(config.rootDir, relativePath);
const body = createTextBytes(size, index, config.seed);
await Deno.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 = toPath(config.rootDir, relativePath);
const body = new Uint8Array(size);
for (let i = 0; i < size; i++) {
body[i] = nextRandom() & 0xff;
}
await Deno.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,
};
}
-685
View File
@@ -1,685 +0,0 @@
/**
* 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 = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1";
const trackedContainers = new Set<string>();
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
let signalCleanupHandlersInstalled = false;
let signalCleanupInProgress = false;
const signalCleanupHandlers = new Map<Deno.Signal, () => 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 = new Deno.Command(bin, {
args,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn();
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(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 instanceof Deno.errors.NotFound) {
return {
code: 127,
stdout: "",
stderr: `Command not found: ${bin}`,
};
}
throw err;
}
}
async function resolveDockerInvoker(): Promise<DockerInvoker> {
const custom = Deno.env.get("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 = (Deno.env.get("LIVESYNC_DOCKER_MODE") ?? "auto").toLowerCase();
const onWindows = Deno.build.os === "windows";
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: Deno.Signal): Promise<void> {
if (signalCleanupInProgress) return;
signalCleanupInProgress = true;
try {
await cleanupTrackedContainers(`signal ${signal}`);
} finally {
Deno.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 {
Deno.addSignalListener(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 {
Deno.removeSignalListener(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);
}
-26
View File
@@ -1,26 +0,0 @@
/**
* 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 Deno.readTextFile(filePath);
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;
}
@@ -1,61 +0,0 @@
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: Deno.CommandOutput;
try {
output = await new Deno.Command("/usr/bin/time", {
args: ["-f", `${MARKER}%U\t%S\t%M`, "node", CLI_DIST, ...args],
cwd: CLI_DIR,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
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,
};
}
-68
View File
@@ -1,68 +0,0 @@
type WaitForPortOptions = {
timeoutMs?: number;
intervalMs?: number;
connectTimeoutMs?: number;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const connPromise = Deno.connect({ hostname, port });
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`connect timeout after ${timeoutMs}ms`)), timeoutMs);
});
const conn = await Promise.race([connPromise, timeoutPromise]);
conn.close();
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
export async function waitForPort(hostname: string, port: number, options: WaitForPortOptions = {}): Promise<void> {
const timeoutMs = options.timeoutMs ?? 15000;
const intervalMs = options.intervalMs ?? 250;
const connectTimeoutMs = options.connectTimeoutMs ?? 1000;
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < timeoutMs) {
try {
await connectWithTimeout(hostname, port, connectTimeoutMs);
return;
} catch (error) {
lastError = error;
await sleep(intervalMs);
}
}
throw new Error(
`Port ${hostname}:${port} did not become ready within ${timeoutMs}ms` +
(lastError ? ` (last error: ${String(lastError)})` : "")
);
}
export async function getOptimalLoopbackIp(): Promise<string> {
const ipv4 = "127.0.0.1";
const ipv6 = "::1";
try {
const l = Deno.listen({ hostname: ipv4, port: 0 });
l.close();
return ipv4;
} catch {
try {
const l = Deno.listen({ hostname: ipv6, port: 0 });
l.close();
return ipv6;
} catch {
return ipv4; // fallback to default
}
}
}
-115
View File
@@ -1,115 +0,0 @@
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(Deno.env.get("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(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
intervalMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_INTERVAL_MS") ?? "250"),
connectTimeoutMs: Number(Deno.env.get("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(() => {});
}
}
-219
View File
@@ -1,219 +0,0 @@
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 scriptPath = await Deno.makeTempFile({
dir: CLI_DIR,
prefix: "livesync-setup-uri-",
suffix: ".mts",
});
await Deno.writeTextFile(scriptPath, script);
try {
const cmd = new Deno.Command("npx", {
args: ["tsx", scriptPath],
cwd: CLI_DIR,
env: {
SETTINGS_FILE: settingsFile,
SETUP_PASSPHRASE: setupPassphrase,
},
stdin: "null",
stdout: "piped",
stderr: "piped",
});
const { code, stdout, stderr } = await cmd.output();
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;
} finally {
await Deno.remove(scriptPath).catch(() => {});
}
}
/** Set isConfigured=true in a settings file (required for mirror / scan). */
export async function markSettingsConfigured(settingsFile: string): Promise<void> {
const data = JSON.parse(await Deno.readTextFile(settingsFile));
data.isConfigured = true;
await Deno.writeTextFile(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 Deno.readTextFile(settingsFile));
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 Deno.writeTextFile(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 Deno.readTextFile(settingsFile));
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 Deno.writeTextFile(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 Deno.readTextFile(settingsFile));
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 Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
}
export async function applyP2pTestTweaks(settingsFile: string, deviceName: string, passphrase: string): Promise<void> {
const data = JSON.parse(await Deno.readTextFile(settingsFile));
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 Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
}
-33
View File
@@ -1,33 +0,0 @@
import { join } from "@std/path";
/**
* A temporary directory that cleans itself up via `await using`.
* Requires TypeScript 5.2+ / Deno 1.40+ for the AsyncDisposable protocol.
*
* @example
* ```ts
* await using tmp = await TempDir.create();
* const file = tmp.join("data.json");
* ```
*/
export class TempDir implements AsyncDisposable {
readonly path: string;
private constructor(path: string) {
this.path = path;
}
static async create(prefix = "livesync-deno-test"): Promise<TempDir> {
const path = await Deno.makeTempDir({ prefix: `${prefix}.` });
return new TempDir(path);
}
/** Return an OS path joined to the temp directory root. */
join(...parts: string[]): string {
return join(this.path, ...parts);
}
async [Symbol.asyncDispose](): Promise<void> {
await Deno.remove(this.path, { recursive: true }).catch(() => {});
}
}
@@ -1,129 +0,0 @@
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();
}
-19
View File
@@ -1,19 +0,0 @@
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 = new Deno.Command(Deno.execPath(), {
args: ["task", task],
cwd: import.meta.dirname,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).spawn();
const status = await child.status;
if (!status.success) {
console.error(`[CLI E2E] ${task} failed with exit code ${status.code}.`);
Deno.exit(status.code);
}
}
console.log(`\n[CLI E2E] CI suite passed (${TASKS.length} tasks).`);
-15
View File
@@ -1,15 +0,0 @@
#!/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 deno task "$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
-50
View File
@@ -1,50 +0,0 @@
const repositoryRoot = await Deno.realPath(new URL("../../../../", import.meta.url));
const composeArgs = ["compose", "-f", "test/bench-network/compose.yml"];
const p2pEnvironment = {
CLI_E2E_TASK: Deno.env.get("CLI_E2E_TASK") ?? "test:p2p:ci",
RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/",
PEERS_TIMEOUT: Deno.env.get("PEERS_TIMEOUT") ?? "20",
SYNC_TIMEOUT: Deno.env.get("SYNC_TIMEOUT") ?? "60",
LIVESYNC_USE_COTURN: Deno.env.get("LIVESYNC_USE_COTURN") ?? "0",
TURN_SERVERS: Deno.env.get("TURN_SERVERS") ?? "none",
LIVESYNC_P2P_PEERS_RETRY: Deno.env.get("LIVESYNC_P2P_PEERS_RETRY") ?? "1",
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "60000",
BENCH_LIVESYNC_TEST_TEE: Deno.env.get("BENCH_LIVESYNC_TEST_TEE") ?? "0",
LIVESYNC_CLI_DEBUG: Deno.env.get("LIVESYNC_CLI_DEBUG") ?? "0",
LIVESYNC_CLI_VERBOSE: Deno.env.get("LIVESYNC_CLI_VERBOSE") ?? "0",
};
async function runDocker(args: string[], env?: Record<string, string>): Promise<Deno.CommandStatus> {
return await new Deno.Command("docker", {
args,
cwd: repositoryRoot,
env,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).spawn().status;
}
let testStatus: Deno.CommandStatus | 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) {
Deno.exit(cleanupStatus.code);
}
}
}
if (!testStatus?.success) {
const code = testStatus?.code ?? 1;
console.error(`[CLI E2E] Compose P2P suite failed with exit code ${code}.`);
Deno.exit(code);
}
console.log("\n[CLI E2E] Compose P2P suite passed.");
@@ -1,333 +0,0 @@
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
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";
function getFreePort(): number {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
try {
return (listener.addr as Deno.NetAddr).port;
} finally {
listener.close();
}
}
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;
}
Deno.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`,
);
assertEquals(
testCase.env.BENCH_MEASUREMENT_SCOPE,
testCase.measurementScope,
);
assertEquals(parsedLimitations(testCase), testCase.limitations);
assertEquals(
testCase.env.BENCH_VERIFY_MODE,
"all",
`${testCase.name} must verify the complete dataset`,
);
}
});
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
const backendPort = getFreePort();
const proxyPort = getFreePort();
const delays: number[] = [];
const backend = Deno.serve(
{
hostname: "127.0.0.1",
port: backendPort,
onListen() {},
},
() => new Response("ok"),
);
const proxy = 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`);
assertEquals(await response.text(), "ok");
assertEquals(proxy.directionalDelayMs, 10);
assertEquals(delays, [10, 10]);
assertEquals(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",
});
assertEquals(await posted.text(), "ok");
assertEquals(proxy.snapshotCounters(), {
requestCount: 1,
requestBodyBytes: 3,
responseBodyBytes: 2,
});
} finally {
await proxy.stop();
await backend.shutdown();
}
const halfMillisecondProxy = startCouchdbProxy({
backendUri: "http://127.0.0.1:1",
proxyUri: `http://127.0.0.1:${getFreePort()}`,
requestedRttMs: 1,
delay: () => Promise.resolve(),
});
try {
assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5);
} finally {
await halfMillisecondProxy.stop();
}
});
Deno.test("compression benchmark dataset covers representative file kinds deterministically", async () => {
const fixtureRoot = await Deno.makeTempDir({
prefix: "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 Deno.mkdir(
absolutePath.slice(0, absolutePath.lastIndexOf("/")),
{ recursive: true },
);
await Deno.writeFile(
absolutePath,
new TextEncoder().encode(
`fixture-${index}-${relativePath}\n`.repeat(20),
),
);
}
await Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
const jpegEncoder = async (_input: string, output: string) => {
await Deno.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,
});
assertEquals(first.filesByKind, {
md: 3,
jpg: 2,
png: 2,
json: 2,
ts: 2,
gz: 1,
bin: 1,
});
assertEquals(first.totalFiles, 13);
assertEquals(first.jpegGenerator, "contract JPEG encoder");
assertEquals(
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 Deno.remove(fixtureRoot, { recursive: true }).catch(() => {});
}
});
Deno.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 },
];
assertEquals(parseBenchmarkVerificationMode("ALL"), "all");
assertEquals(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"));
});
Deno.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);
assertEquals(testCase.runner, "p2p");
assertEquals(testCase.env.BENCH_TURN_SERVERS, "");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(
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`,
);
}
});
Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => {
const cases = buildCases();
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
assertEquals(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",
);
});
Deno.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);
assertEquals(testCase.runner, "couchdb");
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
assertEquals(
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`,
);
}
});
@@ -1,152 +0,0 @@
import { assertEquals } from "@std/assert";
import { join } from "@std/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 = Deno.env.get(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 Deno.readTextFile(filePath);
if (actual === expected) return;
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) 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 Deno.stat(filePath);
} catch (error) {
if (error instanceof Deno.errors.NotFound) 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(() => {});
}
Deno.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([
Deno.mkdir(databaseA, { recursive: true }),
Deno.mkdir(databaseB, { recursive: true }),
Deno.mkdir(databaseC, { recursive: true }),
Deno.mkdir(vaultA, { recursive: true }),
Deno.mkdir(vaultB, { recursive: true }),
Deno.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 Deno.mkdir(join(vaultA, "notes"), { recursive: true });
await Deno.writeTextFile(startupFileA, "created before daemon startup\n");
const initialTime = new Date(Date.now() - 10_000);
await Deno.utime(startupFileA, initialTime, initialTime);
await Deno.writeTextFile(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.
assertEquals(await daemonA.stop(), 0, daemonA.combined);
daemonA = undefined;
await Deno.writeTextFile(startupFileA, "edited while daemon was stopped\n");
await Deno.remove(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");
assertEquals(await Deno.readTextFile(startupFileB), "edited while daemon was stopped\n");
assertEquals((await Deno.stat(remoteOnlyFileB)).isFile, true);
} finally {
await stopDaemon(daemonB);
await stopDaemon(daemonA);
await stopCouchdb().catch(() => {});
}
});
-120
View File
@@ -1,120 +0,0 @@
/**
* Deno 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:
* deno test -A test-daemon.ts
*/
import { join } from "@std/path";
import { assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
Deno.test("daemon: ignore rules behaviour", async (t) => {
// -------------------------------------------------------------------------
// Case 1: .livesync/ignore with *.tmp → ignored file not synced to database
// -------------------------------------------------------------------------
await t.step("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 Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n");
await Deno.writeTextFile(join(vaultDir, "notes", "normal.md"), "normal content\n");
await Deno.writeTextFile(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 Deno.readTextFile(resultNormal);
assertEquals(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.step("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 Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
// No .livesync directory at all.
await Deno.writeTextFile(join(vaultDir, "notes", "hello.md"), "hello\n");
console.log("[INFO] Running mirror for Case 2...");
const result = await runCli(vaultDir, "--settings", settingsFile, "mirror");
assertEquals(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 Deno.readTextFile(resultHello);
assertEquals(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.step("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 Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
await initSettingsFile(settingsFile);
await markSettingsConfigured(settingsFile);
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n");
await Deno.writeTextFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n");
await Deno.writeTextFile(join(vaultDir, "notes", "regular.md"), "regular note\n");
await Deno.writeTextFile(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");
});
});
@@ -1,87 +0,0 @@
/**
* Deno 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:
* deno test -A test-decoupled-vault.ts
*/
import { join } from "@std/path";
import { assertEquals } from "@std/assert";
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 = Deno.env.get("REMOTE_PATH") ?? "test/push-pull-decoupled.txt";
Deno.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 Deno.mkdir(join(vaultDir, "test"), { recursive: true });
await Deno.mkdir(dbDir, { recursive: true });
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `decoupled-${Date.now()}`;
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("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 Deno.writeTextFile(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 Deno.readTextFile(pulledFile);
assertEquals(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 Deno.remove(pulledFile).catch(() => {});
await Deno.remove(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 Deno.readTextFile(restoredFile);
assertEquals(restored, content, "mirror with decoupled vault content mismatch");
console.log("[PASS] mirror with decoupled vault matched");
} finally {
if (shouldStartDocker && !keepDocker) {
await stopCouchdb().catch(() => {});
}
}
});
@@ -1,303 +0,0 @@
import { assert } from "@std/assert";
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 = Deno.env.get(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 Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
const keepDocker = Deno.env.get("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 Deno.writeTextFile(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 Deno.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(() => {});
}
}
}
}
Deno.test("e2e: two vaults over CouchDB without encryption", async () => {
await runScenario("COUCHDB", false);
});
Deno.test("e2e: two vaults over CouchDB with encryption", async () => {
await runScenario("COUCHDB", true);
});
@@ -1,20 +0,0 @@
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) {
Deno.test(`e2e matrix: ${tc.label}`, async () => {
await runScenario(tc.remoteType, tc.encrypt);
});
}
-248
View File
@@ -1,248 +0,0 @@
/**
* Deno 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:
* deno test -A test-mirror.ts
*/
import { assert, assertEquals } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
Deno.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 Deno.mkdir(workDir.join("vault", "test"), { recursive: true });
await Deno.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 Deno.readTextFile(settingsFile));
data.writeDocumentsIfConflicted = true;
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
// Copy settings to the DB directory (separated-path mode)
const dbSettings = workDir.join("db", "settings.json");
await Deno.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.step("case 1: storage-only file is synced into DB", async () => {
const storageFile = workDir.join("vault", "test", "storage-only.md");
await Deno.writeTextFile(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 Deno.readTextFile(storageFile);
const pulledContent = await Deno.readTextFile(resultFile);
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.step("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 Deno.writeTextFile(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 Deno.readTextFile(storagePath);
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.step("case 3: DB-deleted entry is NOT restored to storage", async () => {
const deletedSrc = workDir.join("deleted-src.txt");
await Deno.writeTextFile(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.step("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 Deno.writeTextFile(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();
assertEquals(await Deno.readTextFile(workDir.join("vault", "test", "sync-storage-newer.md")), "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 Deno.writeTextFile(storageFile, "new content\n");
await Deno.utime(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"));
assertEquals(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 Deno.readTextFile(storageFile);
const pulledContent = await Deno.readTextFile(resultFile);
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.step("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 Deno.writeTextFile(storageFile, "old storage content\n");
await Deno.utime(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 Deno.writeTextFile(dbNewFile, "new db content\n");
await dbRun("push", dbNewFile, "test/sync-db-newer.md");
await runMirror();
const content = await Deno.readTextFile(storageFile);
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.step("case 6: compatibility mode (omitted vault-path)", async () => {
const compatFile = workDir.join("vault", "compat.md");
await Deno.writeTextFile(compatFile, "compat-content\n");
await runMirrorCompat();
const resultFile = workDir.join("case6-pull.txt");
await compatRun("pull", "compat.md", resultFile);
const pulled = await Deno.readTextFile(resultFile);
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.step("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 Deno.writeTextFile(storageFile, "unrelated local content\n");
await Deno.utime(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);
}
assertEquals([...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));
assertEquals(repeated.revision, info.revision);
assertEquals(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));
assertEquals(resolved.conflicts, "N/A");
assertEquals(resolved.revision, localRevision);
assertEquals(await Deno.readTextFile(storageFile), "unrelated local content\n");
});
});
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async function exists(path: string): Promise<boolean> {
try {
await Deno.stat(path);
return true;
} catch {
return false;
}
}
@@ -1,422 +0,0 @@
import { assert, assertEquals } from "@std/assert";
import { join } from "@std/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<Deno.CommandStatus>;
#stopped = false;
private constructor(readonly child: Deno.ChildProcess) {
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 = new Deno.Command("node", {
args: [join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), vaultPath, "--settings", settingsPath, "p2p-host"],
cwd: CLI_DIR,
env,
stdin: "piped",
stdout: "piped",
stderr: "piped",
}).spawn();
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 Deno.readTextFile(settingsPath));
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 Deno.writeTextFile(settingsPath, JSON.stringify(settings, null, 2));
}
Deno.test("p2p-host: restored automatic policy syncs clients through a persistent middle peer", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
const peersTimeoutMs = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20") * 1000;
const syncTimeoutMs = Number(Deno.env.get("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 = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("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([
Deno.mkdir(hostVault, { recursive: true }),
Deno.mkdir(clientAVault, { recursive: true }),
Deno.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");
assertEquals(
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);
assertEquals(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);
assertEquals(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");
assertEquals(
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);
}
});
-40
View File
@@ -1,40 +0,0 @@
import { assert } from "@std/assert";
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";
Deno.test("p2p-host: starts and becomes ready", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests";
const useInternalRelay = Deno.env.get("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 Deno.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(() => {});
}
}
});
@@ -1,76 +0,0 @@
import { assert } from "@std/assert";
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";
Deno.test("p2p-peers: discovers host through local relay", async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const timeoutSeconds = Number(Deno.env.get("TIMEOUT_SECONDS") ?? "8");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("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 Deno.mkdir(hostVault, { recursive: true });
await Deno.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);
}
});
@@ -1,26 +0,0 @@
import { assert, assertEquals } from "@std/assert";
Deno.test("p2p lifecycle: explicit disconnect closes and pauses relay WebSockets", async () => {
const command = new Deno.Command("node", {
args: [new URL("./relay-disconnect-probe.mjs", import.meta.url).pathname],
env: {
RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/",
RELAY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000",
},
stdout: "piped",
stderr: "piped",
});
const result = await command.output();
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");
assertEquals(report.stayedDisconnectedWhilePaused, true);
assertEquals(report.reconnectedAfterResume, true);
});
@@ -1,114 +0,0 @@
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { join } from "@std/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";
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 command = new Deno.Command("node", {
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",
});
const result = await command.output();
return {
code: result.code,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}
Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000;
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const roomId = Deno.env.get("ROOM_ID") ?? `replacement-room-${nonce}`;
const passphrase = Deno.env.get("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 = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("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([
Deno.mkdir(hostVault, { recursive: true }),
Deno.mkdir(probeVault, { recursive: true }),
Deno.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}`
);
assertEquals(sanitiseCatStdout(catResult.stdout).trim(), NOTE_CONTENT);
} finally {
await host.stop();
}
} finally {
await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
}
});
-168
View File
@@ -1,168 +0,0 @@
import { assert } from "@std/assert";
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";
Deno.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 = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "12");
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("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 Deno.mkdir(hostVault, { recursive: true });
await Deno.mkdir(clientVault, { recursive: true });
await Deno.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 Deno.readTextFile(path));
settings.idDerivationVersion = 1;
settings.idDerivationKey = key;
const sourcePath = workDir.join("setup-source-" + label + ".json");
await Deno.writeTextFile(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 Deno.readTextFile(path));
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,
Deno.env.get("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);
}
});
@@ -1,141 +0,0 @@
import { assert } from "@std/assert";
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";
Deno.test("p2p: three nodes detect and resolve conflicts", async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
const appId = "self-hosted-livesync-cli-tests";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "10");
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
const peerNameB = Deno.env.get("PEER_NAME_B") ?? `p2p-client-b-${nonce}`;
const peerNameC = Deno.env.get("PEER_NAME_C") ?? `p2p-client-c-${nonce}`;
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
const turnServers = Deno.env.get("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 Deno.mkdir(vaultA, { recursive: true });
await Deno.mkdir(vaultB, { recursive: true });
await Deno.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);
}
});
@@ -1,111 +0,0 @@
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 Deno.writeFile(path, data);
}
Deno.test("p2p: upload/download reproduction scenario", async () => {
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests";
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
const syncTimeout = Number(Deno.env.get("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 Deno.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 Deno.writeTextFile(storeText, "Hello, World!\n");
await Deno.writeTextFile(diffA, "Content A\n");
await Deno.writeTextFile(diffB, "Content B\n");
await Deno.writeTextFile(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 Deno.writeTextFile(large100k, "a".repeat(100000));
await Deno.writeTextFile(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);
}
});
-78
View File
@@ -1,78 +0,0 @@
/**
* Deno 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:
* deno test -A 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 \
* deno test -A test-push-pull.ts
*/
import { join } from "@std/path";
import { assertEquals } from "@std/assert";
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 = Deno.env.get("REMOTE_PATH") ?? "test/push-pull.txt";
Deno.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 Deno.mkdir(join(vaultDir, "test"), { recursive: true });
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `push-pull-${Date.now()}`;
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("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 Deno.writeTextFile(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 Deno.readTextFile(pulledFile);
assertEquals(content, pulled, "push/pull roundtrip content mismatch");
console.log("[PASS] push/pull roundtrip matched");
} finally {
if (shouldStartDocker && !keepDocker) {
await stopCouchdb().catch(() => {});
}
}
});
@@ -1,77 +0,0 @@
import { assertEquals, assertStringIncludes } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { runCli } from "./helpers/cli.ts";
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
async function prepareFixture(prefix: string) {
const workDir = await TempDir.create(prefix);
const settingsFile = workDir.join("settings.json");
const databaseDir = workDir.join("database");
await Deno.mkdir(databaseDir, { recursive: true });
await initSettingsFile(settingsFile);
return { workDir, settingsFile, databaseDir };
}
Deno.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");
assertEquals(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"
);
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
assertStringIncludes(
compatibilitySuccess.combined,
"[Verification] Remote administration is unavailable for this provider."
);
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
});
Deno.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"
);
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
});
@@ -1,126 +0,0 @@
/**
* Deno 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:
* deno test -A test-remote-commands.ts
*/
import { join } from "@std/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;
}
Deno.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 Deno.mkdir(vaultDir, { recursive: true });
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `remotes-${dbSuffix}`;
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("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(() => {});
}
}
});
@@ -1,134 +0,0 @@
import { assert, assertEquals } from "@std/assert";
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 Deno.mkdir(databaseDir, { recursive: true });
await initSettingsFile(settingsFile);
return { workDir, settingsFile, databaseDir };
}
Deno.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 Deno.readTextFile(settingsFile));
settings.liveSync = true;
settings.syncOnStart = true;
settings.periodicReplication = true;
settings.P2P_Enabled = true;
settings.P2P_AutoStart = true;
settings.P2P_AutoBroadcast = true;
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
const result = await runCli(
databaseDir,
"--settings",
settingsFile,
"remote-add",
"test-remote",
"sls+https://user:pass@example.com/database"
);
assertEquals(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 Deno.readTextFile(settingsFile));
let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
assert(
remotes.some((remote) => remote.name === "test-remote"),
"remote-add did not persist the new profile"
);
assertEquals(persisted.liveSync, true);
assertEquals(persisted.syncOnStart, true);
assertEquals(persisted.periodicReplication, true);
assertEquals(persisted.P2P_Enabled, true);
assertEquals(persisted.P2P_AutoStart, true);
assertEquals(persisted.P2P_AutoBroadcast, true);
const secondAdd = await runCli(
databaseDir,
"--settings",
settingsFile,
"remote-add",
"second-remote",
"sls+https://other:secret@example.net/second"
);
assertEquals(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);
assertEquals(activate.code, 0, activate.combined);
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
assertEquals(persisted.activeConfigurationId, secondRemoteId);
const set = await runCli(
databaseDir,
"--settings",
settingsFile,
"remote-set",
secondRemoteId,
"sls+https://replacement:secret@example.org/replaced"
);
assertEquals(set.code, 0, set.combined);
const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId);
assertEquals(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);
assertEquals(remove.code, 0, remove.combined);
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
assertEquals(persisted.activeConfigurationId, firstRemoteId);
});
Deno.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 Deno.readTextFile(settingsFile));
settings.settingVersion = 9;
const original = JSON.stringify(settings, null, 2);
await Deno.writeTextFile(settingsFile, original);
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
assertEquals(result.code, 0, result.combined);
assertEquals(await Deno.readTextFile(settingsFile), original);
});
Deno.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 Deno.readTextFile(settingsFile));
settings.settingVersion = 9;
delete settings.useIndexedDBAdapter;
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls");
assertEquals(result.code, 0, result.combined);
const persisted = JSON.parse(await Deno.readTextFile(settingsFile));
assertEquals(persisted.settingVersion, 10);
assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file");
});
Deno.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 Deno.readTextFile(settingsFile);
const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote");
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
assertEquals(await Deno.readTextFile(settingsFile), original);
});
-221
View File
@@ -1,221 +0,0 @@
/**
* Deno 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):
* deno test -A test-setup-put-cat.ts
*/
import { join } from "@std/path";
import { assertEquals, assert } from "@std/assert";
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 = Deno.env.get("REMOTE_PATH") ?? "test/setup-put-cat.txt";
const SETUP_PASSPHRASE = Deno.env.get("SETUP_PASSPHRASE") ?? "setup-passphrase";
Deno.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 Deno.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 Deno.readTextFile(settingsFile));
assertEquals(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.step("push/cat roundtrip", async () => {
const srcFile = workDir.join("put-source.txt");
const content = `setup-put-cat-test ${new Date().toISOString()}\nline-2\n`;
await Deno.writeTextFile(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);
assertEquals(content, catOutput, "push/cat roundtrip content mismatch");
console.log("[PASS] push/cat roundtrip matched");
});
// ------------------------------------------------------------------
// ls: single file
// ------------------------------------------------------------------
await t.step("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");
assertEquals(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.step("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.step("ls no-match prefix returns empty", async () => {
const lsOut = await run("ls", "no-such-prefix/");
assertEquals(lsOut.trim(), "", "ls no-match prefix should produce empty output");
console.log("[PASS] ls no-match prefix matched");
});
// ------------------------------------------------------------------
// info: JSON output format
// ------------------------------------------------------------------
await t.step("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}`);
}
assertEquals(data.path, REMOTE_PATH, "info .path mismatch");
assertEquals(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}`);
assertEquals(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.step("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.step("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.step("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.step("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 Deno.readTextFile(pullRevFile);
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");
});
});
@@ -1,93 +0,0 @@
/**
* Deno port of test-sync-locked-remote-linux.sh
*
* Verifies CLI sync behaviour when the remote milestone document is unlocked
* versus locked.
*/
import { assert, assertStringIncludes } from "@std/assert";
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";
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
function requireEnv(...keys: string[]): string {
for (const key of keys) {
const value = Deno.env.get(key)?.trim();
if (value) return value;
}
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
}
Deno.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 Deno.mkdir(vaultDir, { recursive: true });
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const keepDocker = Deno.env.get("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(() => {});
}
}
});
@@ -1,272 +0,0 @@
/**
* Deno 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:
* deno test -A 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 \
* deno test -A test-sync-two-local-databases.ts
*/
import { assertEquals, assert } from "@std/assert";
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 = Deno.env.toObject();
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 = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
const KEEP_DOCKER = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
const SYNC_RETRY = Number(Deno.env.get("LIVESYNC_SYNC_RETRY") ?? "8");
// Provide a sane default for flaky remote connectivity in Docker-on-WSL
// environments. Users can override explicitly if needed.
if (!Deno.env.has("LIVESYNC_CLI_RETRY")) {
Deno.env.set("LIVESYNC_CLI_RETRY", "2");
}
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
Deno.test(
{
name: "sync two local databases: sync + conflict detection + resolution",
ignore: 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: Deno.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 Deno.mkdir(vaultA, { recursive: true });
await Deno.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.step("case 1: A creates file -> B can read after sync", async () => {
const srcA = workDir.join("from-a-src.txt");
await Deno.writeTextFile(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();
assertEquals(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.step("case 2: B creates file -> A can read after sync", async () => {
const srcB = workDir.join("from-b-src.txt");
await Deno.writeTextFile(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();
assertEquals(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.step("case 3: concurrent edits create conflict", async () => {
const baseSrc = workDir.join("base-src.txt");
await Deno.writeTextFile(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 Deno.writeTextFile(aEdit, "edit-from-a\n");
await Deno.writeTextFile(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 Deno.writeTextFile(infoFileA, await runA("info", "shared/conflicted.txt"));
await Deno.writeTextFile(infoFileB, await runB("info", "shared/conflicted.txt"));
const da = JSON.parse(await Deno.readTextFile(infoFileA)) as Record<string, unknown>;
const db = JSON.parse(await Deno.readTextFile(infoFileB)) 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.step("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 Deno.writeTextFile(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 Deno.writeTextFile(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 Deno.writeTextFile(infoFileA, rawA2);
await Deno.writeTextFile(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");
assertEquals(contentA, contentB, "resolved content mismatch between A and B");
console.log("[PASS] case 4 passed");
console.log("[PASS] all sync/resolve scenarios passed");
});
}
+2 -350
View File
@@ -1,351 +1,3 @@
# CLI Deno Test Development Notes
# Moved
This document provides an overview of the Deno-based compatibility tests under `src/apps/cli/testdeno/`.
The Deno suite is the canonical CLI E2E entry point. P2P scenarios run through the repository Compose entry point so that networking, signalling, and the runner environment are reproducible. Existing Bash tests under `src/apps/cli/test/` remain as legacy implementation references, but are not exposed as supported P2P entry points.
---
## Goals
- Keep the existing Bash tests as migration references while using Deno and Compose for supported execution.
- Provide direct execution from Windows PowerShell.
- Establish a TypeScript (Deno) foundation for core end-to-end and integration scenarios.
---
## Directory structure
```
src/apps/cli/testdeno/
deno.json
run-ci-suite.ts
run-compose-p2p.ts
CONTRIBUTING_TESTS.md
helpers/
backgroundCli.ts
cli.ts
docker.ts
env.ts
p2p.ts
settings.ts
temp.ts
test-e2e-two-vaults-couchdb.ts
test-push-pull.ts
test-p2p-host.ts
test-p2p-peers-local-relay.ts
test-p2p-sync.ts
test-p2p-automatic-host.ts
test-p2p-three-nodes-conflict.ts
test-p2p-upload-download-repro.ts
test-e2e-two-vaults-matrix.ts
test-setup-put-cat.ts
test-mirror.ts
test-sync-two-local-databases.ts
test-sync-locked-remote.ts
test-daemon.ts
test-decoupled-vault.ts
test-remote-commands.ts
```
---
## Key files
### `deno.json`
- Defines Deno tasks.
- Defines import maps for `@std/assert` and `@std/path`.
Main tasks:
- `deno task test`
- `deno task test:ci`
- `deno task test:p2p:compose`
- `deno task test:local`
- `deno task test:daemon`
- `deno task test:decoupled-vault`
- `deno task test:remote-commands`
- `deno task test:push-pull`
- `deno task test:setup-put-cat`
- `deno task test:mirror`
- `deno task test:sync-two-local`
- `deno task test:sync-locked-remote`
- `deno task test:p2p-host`
- `deno task test:p2p-peers`
- `deno task test:p2p-sync`
- `deno task test:p2p-automatic-host`
- `deno task test:p2p-three-nodes`
- `deno task test:p2p-upload-download`
- `deno task test:e2e-couchdb`
- `deno task test:e2e-matrix`
`deno task test` is an alias for the non-P2P `test:ci` suite. The individual P2P tasks are explicit host-direct entry points for cross-platform diagnostics; they are never selected by the default suite or CI. Use `test:p2p:compose` for canonical P2P verification.
### `helpers/cli.ts`
- CLI execution wrappers.
- `runCli`, `runCliOrFail`, `runCliWithInput`.
- Output normalisation via `sanitiseCatStdout`.
- Comparison utilities, including `assertFilesEqual`.
This file corresponds to `run_cli` and common assertions in `test-helpers.sh`.
### `helpers/settings.ts`
- Executes `init-settings --force`.
- Marks `isConfigured = true`.
- Applies CouchDB and P2P settings.
- Applies remote synchronisation settings and P2P test tweaks.
This file corresponds to settings helpers in `test-helpers.sh`.
### `helpers/docker.ts`
- Starts, stops, and initialises CouchDB and RustFS directly from Deno.
- Configures CouchDB via `fetch + retry`.
- Initialises S3 buckets using the RustFS `rc` client, including CORS for signed browser requests.
- Starts and stops the P2P relay through the same Docker runner.
These flows do not require Bash on the host. The S3 matrix tasks, environment variables, and container name retain their existing `minio` names for compatibility; RustFS provides the test backend. The RustFS server and `rc` client images are pinned by version and digest.
### `helpers/backgroundCli.ts`
- Starts long-running commands such as `p2p-host` in the background.
- Waits for readiness logs and handles termination.
### `helpers/p2p.ts`
- Determines whether a local relay should be started.
- Parses `p2p-peers` output.
- Discovers peer IDs with a fallback based on advertisement logs.
### `helpers/env.ts`
- Loads `.test.env`.
- Supports `KEY=value`, single-quoted values, and double-quoted values.
### `helpers/temp.ts`
- Provides `TempDir`.
- Uses `await using` to auto-clean temporary directories.
---
## Implemented tests
### `test-push-pull.ts`
- Verifies push and pull round trips.
- Uses environment variables or `.test.env` for CouchDB values.
### `test-setup-put-cat.ts`
- Verifies `setup` with full setup URI generation via `encodeSettingsToSetupURI`.
- Verifies `push`, `cat`, `ls`, `info`, `rm`, `resolve`, `cat-rev`, and `pull-rev`.
- Does not require an external remote.
### `test-mirror.ts`
- Verifies seven core mirror scenarios, including an unrecognised revision origin.
- Does not require an external remote.
### `test-sync-two-local-databases.ts`
- Verifies sync between two vaults and CouchDB.
- Verifies conflict detection and resolve propagation.
- Starts Docker CouchDB by default when `LIVESYNC_START_DOCKER != 0`.
### `test-sync-locked-remote.ts`
- Updates the CouchDB milestone `locked` flag.
- Verifies sync success when unlocked.
- Verifies actionable CLI error when locked.
### `test-p2p-host.ts`
- Verifies that `p2p-host` starts and emits readiness output.
### `test-p2p-peers-local-relay.ts`
- Verifies peer discovery through a local relay.
### `test-p2p-sync.ts`
- Verifies that `p2p-sync` completes after peer discovery.
### `test-p2p-automatic-host.ts`
- Verifies that `p2p-host` restores its configured P2P automation policy and stays connected while multiple clients synchronise through it.
- Covers automatic baseline transfer, watched updates, client disconnection, and host restart persistence without manual P2P sync commands.
### `test-p2p-three-nodes-conflict.ts`
- Uses one host and two clients.
- Verifies conflict creation, detection via `info`, and resolution via `resolve`.
### `test-p2p-upload-download-repro.ts`
- Uses host, upload, and download nodes.
- Verifies transfer of text files and binary files, including larger files.
### `test-e2e-two-vaults-couchdb.ts`
- Verifies two-vault end-to-end scenarios on CouchDB.
- Runs both encryption-off and encryption-on cases.
- Includes conflict marker checks in `ls` and resolve propagation checks.
### `test-e2e-two-vaults-matrix.ts`
- Verifies the matrix equivalent of the bash script.
- Runs four combinations:
- `COUCHDB-enc0`
- `COUCHDB-enc1`
- `MINIO-enc0`
- `MINIO-enc1`
### `test-daemon.ts`
- Verifies daemon-related ignore rules behaviour.
- Exercises scenarios with `.livesync/ignore` wildcard rules, missing ignore rules, and imported `.gitignore` rules.
### `test-decoupled-vault.ts`
- Verifies push, pull, and mirror command behaviour when the vault directory is decoupled from the database directory.
### `test-remote-commands.ts`
- Verifies remote database management commands: `remote-status`, `lock-remote`, `unlock-remote`, and `mark-resolved`.
---
## Running tests (PowerShell)
From the repository root, use the canonical package scripts. `test:e2e:cli` runs the same non-P2P task set selected by the default CLI CI workflow. P2P validation runs in Compose so peer discovery does not depend on host loopback, firewall, or WebRTC candidate behaviour.
```powershell
npm run test:e2e:cli
npm run test:e2e:cli:p2p
npm run test:e2e:cli:all
```
From `src/apps/cli/testdeno`:
```powershell
cd src/apps/cli/testdeno
# Canonical suites
deno task test:ci
deno task test:p2p:compose
# Local-only set
deno task test:local
# Individual tests
deno task test:setup-put-cat
deno task test:mirror
deno task test:daemon
deno task test:push-pull
deno task test:sync-locked-remote
# CouchDB and Object Storage tests
deno task test:sync-two-local
deno task test:decoupled-vault
deno task test:remote-commands
deno task test:e2e-couchdb
deno task test:e2e-matrix
# Explicit host-direct P2P diagnostics for cross-platform investigations.
# These are not part of the default suite or release evidence.
deno task test:p2p-host
deno task test:p2p-peers
deno task test:p2p-sync
deno task test:p2p-three-nodes
deno task test:p2p-upload-download
```
---
## Environment variables
### CouchDB
- `COUCHDB_URI`
- `COUCHDB_USER`
- `COUCHDB_PASSWORD`
- `COUCHDB_DBNAME`
Equivalent keys in `src/apps/cli/.test.env`:
- `hostname`
- `username`
- `password`
- `dbname`
### Behaviour switches
- `LIVESYNC_START_DOCKER=0`: use existing CouchDB.
- `REMOTE_PATH`: override 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 test completion.
### Docker command selection
`helpers/docker.ts` supports command selection via environment variables.
- `LIVESYNC_DOCKER_MODE=auto` (default)
- Windows: tries `wsl docker` first, then `docker`.
- Non-Windows: tries `docker` first, then `wsl docker`.
- `LIVESYNC_DOCKER_MODE=native`: always uses `docker`.
- `LIVESYNC_DOCKER_MODE=wsl`: always uses `wsl docker`.
- `LIVESYNC_DOCKER_COMMAND="..."`: custom command, for example `wsl docker`.
`LIVESYNC_DOCKER_COMMAND` has priority over `LIVESYNC_DOCKER_MODE`.
PowerShell examples:
```powershell
# Use Docker in WSL explicitly
$env:LIVESYNC_DOCKER_MODE = "wsl"
deno task test:sync-two-local
# Full custom command
$env:LIVESYNC_DOCKER_COMMAND = "wsl docker"
deno task test:sync-two-local
```
### P2P
- `RELAY`
- `ROOM_ID`
- `PASSPHRASE`
- `APP_ID`
- `PEERS_TIMEOUT`
- `SYNC_TIMEOUT`
- `USE_INTERNAL_RELAY=0|1`
- `TIMEOUT_SECONDS`
---
## Continuous Integration
The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI or its shared runtime inputs. It builds the CLI once, then distributes the bundle to the non-P2P test jobs. The local `test:ci` runner and the automatic matrix both read `ci-tasks.json`. During a manual dispatch, select a test subset and optionally enable 'Enable verbose and debug logging' for detailed troubleshooting traces.
The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite automatically for changes to the CLI, its shared runtime inputs, or `test/bench-network/`. A change confined to the Compose fixtures does not start the non-P2P matrix. Host-direct `test:p2p` tasks remain available for explicit diagnostics.
The `.github/workflows/cli-docker-validation.yml` workflow builds one production JavaScript bundle with Node.js 22, then tests Docker images in parallel on native amd64 and arm64 runners. Each image uses the shared bundle and prepares its own runtime dependencies. Validation runs without registry credentials, and both architecture results are collected before publication. The publishing workflow, `.github/workflows/cli-docker.yml`, imports the validated OCI image from each architecture, collects the image digests, and publishes the combined manifest. It retains the existing publication conditions for main, release tags, and manual dispatch. A dry run builds both images and checks the digest pair while skipping registry uploads and manifest publication.
---
## Current limitations
- S3 startup and matrix coverage use RustFS. Current limits are elsewhere, not setup URI generation.
---
## Maintenance policy
- Existing bash tests remain available.
- Deno tests are expanded in parallel for cross-platform usage.
- New scenarios should be added through reusable helpers in `helpers/`.
The CLI test development guide is now [test/e2e-cli/test_dev_node.md](../../../../test/e2e-cli/test_dev_node.md).
+1 -1
View File
@@ -27,5 +27,5 @@
}
},
"include": ["*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist", "test", "testdeno"]
"exclude": ["node_modules", "dist", "test"]
}
+10 -21
View File
@@ -49,26 +49,6 @@ if (typeof globalThis.FileReader === "undefined") {
}
`;
function injectBanner(): import("vite").Plugin {
return {
name: "inject-banner",
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk" && chunk.isEntry) {
// Insert after the shebang line if present, otherwise at the top.
if (chunk.code.startsWith("#!")) {
const newline = chunk.code.indexOf("\n");
chunk.code =
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
} else {
chunk.code = fileReaderPolyfillBanner + chunk.code;
}
}
}
},
};
}
const buildInputs: Record<string, string> = {
index: resolve(__dirname, "entrypoint.ts"),
};
@@ -77,7 +57,7 @@ if (process.env.LIVESYNC_CLI_TEST_SUPPORT === "1") {
}
export default defineConfig({
plugins: [svelte(), injectBanner()],
plugins: [svelte()],
resolve: {
// This bundle runs in Node. Vite's client defaults include the `browser`
// export condition, which would select Commonlib's inline Web Worker.
@@ -98,7 +78,16 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
minify: false,
sourcemap: process.env.LIVESYNC_CLI_COVERAGE === "1",
rollupOptions: {
output: {
banner: (chunk) => (chunk.isEntry ? fileReaderPolyfillBanner : ""),
// c8 filters mapped ranges before resolving relative source paths.
sourcemapPathTransform:
process.env.LIVESYNC_CLI_COVERAGE === "1"
? (source: string, mapPath: string) => path.resolve(path.dirname(mapPath), source)
: undefined,
},
input: buildInputs,
external: (id) => {
if (isBuiltin(id)) return true;