mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-17 04:51:17 +00:00
### Fixed
- Fixed flaky timing issues in P2P synchronisation. - Fixed more binary file handling issues in CLI. ### Tests - Rewrite P2P end-to-end tests to use the CLI as host.
This commit is contained in:
@@ -116,6 +116,22 @@ export const acceptWebPeer: BrowserCommand = async (ctx) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Write arbitrary text to a file on the Node.js host (used for phase handoff). */
|
||||
export const writeHandoffFile: BrowserCommand<[filePath: string, content: string]> = async (
|
||||
_ctx,
|
||||
filePath: string,
|
||||
content: string
|
||||
) => {
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
};
|
||||
|
||||
/** Read a file from the Node.js host (used for phase handoff). */
|
||||
export const readHandoffFile: BrowserCommand<[filePath: string]> = async (_ctx, filePath: string): Promise<string> => {
|
||||
const fs = await import("node:fs/promises");
|
||||
return fs.readFile(filePath, "utf-8");
|
||||
};
|
||||
|
||||
export default function BrowserCommands(): Plugin {
|
||||
return {
|
||||
name: "vitest:custom-commands",
|
||||
@@ -128,6 +144,8 @@ export default function BrowserCommands(): Plugin {
|
||||
openWebPeer,
|
||||
closeWebPeer,
|
||||
acceptWebPeer,
|
||||
writeHandoffFile,
|
||||
readHandoffFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -141,5 +159,7 @@ declare module "vitest/browser" {
|
||||
openWebPeer: (setting: P2PSyncSetting, serverPeerName: string) => Promise<void>;
|
||||
closeWebPeer: () => Promise<void>;
|
||||
acceptWebPeer: () => Promise<boolean>;
|
||||
writeHandoffFile: (filePath: string, content: string) => Promise<void>;
|
||||
readHandoffFile: (filePath: string) => Promise<string>;
|
||||
}
|
||||
}
|
||||
|
||||
194
test/suitep2p/run-p2p-tests.sh
Executable file
194
test/suitep2p/run-p2p-tests.sh
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
CLI_DIR="$REPO_ROOT/src/apps/cli"
|
||||
CLI_TEST_HELPERS="$CLI_DIR/test/test-helpers.sh"
|
||||
|
||||
source "$CLI_TEST_HELPERS"
|
||||
|
||||
RUN_BUILD="${RUN_BUILD:-1}"
|
||||
KEEP_TEST_DATA="${KEEP_TEST_DATA:-1}"
|
||||
VERBOSE_TEST_LOGGING="${VERBOSE_TEST_LOGGING:-1}"
|
||||
|
||||
RELAY="${RELAY:-ws://localhost:4000/}"
|
||||
USE_INTERNAL_RELAY="${USE_INTERNAL_RELAY:-1}"
|
||||
APP_ID="${APP_ID:-self-hosted-livesync-vitest-p2p}"
|
||||
HOST_PEER_NAME="${HOST_PEER_NAME:-p2p-cli-host}"
|
||||
|
||||
ROOM_ID="p2p-room-$(date +%s)-$RANDOM-$RANDOM"
|
||||
PASSPHRASE="p2p-pass-$(date +%s)-$RANDOM-$RANDOM"
|
||||
UPLOAD_PEER_NAME="p2p-upload-$(date +%s)-$RANDOM"
|
||||
DOWNLOAD_PEER_NAME="p2p-download-$(date +%s)-$RANDOM"
|
||||
UPLOAD_VAULT_NAME="TestVaultUpload-$(date +%s)-$RANDOM"
|
||||
DOWNLOAD_VAULT_NAME="TestVaultDownload-$(date +%s)-$RANDOM"
|
||||
|
||||
# ---- Build CLI ----
|
||||
if [[ "$RUN_BUILD" == "1" ]]; then
|
||||
echo "[INFO] building CLI"
|
||||
(cd "$CLI_DIR" && npm run build)
|
||||
fi
|
||||
|
||||
# ---- Temp directory ----
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-vitest-p2p.XXXXXX")"
|
||||
VAULT_HOST="$WORK_DIR/vault-host"
|
||||
SETTINGS_HOST="$WORK_DIR/settings-host.json"
|
||||
HOST_LOG="$WORK_DIR/p2p-host.log"
|
||||
# Handoff file: upload phase writes this; download phase reads it.
|
||||
HANDOFF_FILE="$WORK_DIR/p2p-test-handoff.json"
|
||||
mkdir -p "$VAULT_HOST"
|
||||
|
||||
# ---- Setup CLI command (uses npm run cli from CLI_DIR) ----
|
||||
# Override run_cli to invoke the built binary directly from CLI_DIR
|
||||
run_cli() {
|
||||
(cd "$CLI_DIR" && node dist/index.cjs "$@")
|
||||
}
|
||||
|
||||
# ---- Create host settings ----
|
||||
echo "[INFO] relay=$RELAY room=$ROOM_ID app=$APP_ID host=$HOST_PEER_NAME"
|
||||
cli_test_init_settings_file "$SETTINGS_HOST"
|
||||
cli_test_apply_p2p_settings "$SETTINGS_HOST" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*"
|
||||
|
||||
# Set host peer name
|
||||
SETTINGS_HOST_FILE="$SETTINGS_HOST" HOST_PEER_NAME_VAL="$HOST_PEER_NAME" HOST_PASSPHRASE_VAL="$PASSPHRASE" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const data = JSON.parse(fs.readFileSync(process.env.SETTINGS_HOST_FILE, "utf-8"));
|
||||
|
||||
// Keep tweak values aligned with browser-side P2P test settings.
|
||||
data.remoteType = "ONLY_P2P";
|
||||
data.encrypt = true;
|
||||
data.passphrase = process.env.HOST_PASSPHRASE_VAL;
|
||||
data.usePathObfuscation = true;
|
||||
data.handleFilenameCaseSensitive = false;
|
||||
data.customChunkSize = 50;
|
||||
data.usePluginSyncV2 = true;
|
||||
data.doNotUseFixedRevisionForChunks = false;
|
||||
|
||||
data.P2P_DevicePeerName = process.env.HOST_PEER_NAME_VAL;
|
||||
fs.writeFileSync(process.env.SETTINGS_HOST_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||||
NODE
|
||||
|
||||
# ---- Cleanup trap ----
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${HOST_PID:-}" ]] && kill -0 "$HOST_PID" >/dev/null 2>&1; then
|
||||
echo "[INFO] stopping CLI host (PID=$HOST_PID)"
|
||||
kill -TERM "$HOST_PID" >/dev/null 2>&1 || true
|
||||
wait "$HOST_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ "${P2P_RELAY_STARTED:-0}" == "1" ]]; then
|
||||
cli_test_stop_p2p_relay
|
||||
fi
|
||||
|
||||
if [[ "$KEEP_TEST_DATA" != "1" ]]; then
|
||||
rm -rf "$WORK_DIR"
|
||||
else
|
||||
echo "[INFO] KEEP_TEST_DATA=1, preserving artefacts at $WORK_DIR"
|
||||
fi
|
||||
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_host() {
|
||||
local attempt=0
|
||||
while [[ "$attempt" -lt 5 ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "[INFO] starting CLI p2p-host (attempt $attempt/5)"
|
||||
: >"$HOST_LOG"
|
||||
(cd "$CLI_DIR" && node dist/index.cjs "$VAULT_HOST" --settings "$SETTINGS_HOST" -d p2p-host) >"$HOST_LOG" 2>&1 &
|
||||
HOST_PID=$!
|
||||
|
||||
local host_ready=0
|
||||
local exited_early=0
|
||||
for i in $(seq 1 30); do
|
||||
if grep -qF "P2P host is running" "$HOST_LOG" 2>/dev/null; then
|
||||
host_ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$HOST_PID" >/dev/null 2>&1; then
|
||||
exited_early=1
|
||||
break
|
||||
fi
|
||||
echo "[INFO] waiting for p2p-host to be ready... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$host_ready" == "1" ]]; then
|
||||
echo "[INFO] p2p-host is ready (PID=$HOST_PID)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
wait "$HOST_PID" >/dev/null 2>&1 || true
|
||||
HOST_PID=
|
||||
|
||||
if grep -qF "Resource temporarily unavailable" "$HOST_LOG" 2>/dev/null; then
|
||||
echo "[INFO] p2p-host database lock is still being released, retrying..."
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$exited_early" == "1" ]]; then
|
||||
echo "[FAIL] CLI host process exited unexpectedly" >&2
|
||||
else
|
||||
echo "[FAIL] p2p-host did not become ready within 30 seconds" >&2
|
||||
fi
|
||||
cat "$HOST_LOG" >&2
|
||||
exit 1
|
||||
done
|
||||
|
||||
echo "[FAIL] p2p-host could not be restarted after multiple attempts" >&2
|
||||
cat "$HOST_LOG" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- Start local relay if needed ----
|
||||
if [[ "$USE_INTERNAL_RELAY" == "1" ]]; then
|
||||
if cli_test_is_local_p2p_relay "$RELAY"; then
|
||||
cli_test_start_p2p_relay
|
||||
P2P_RELAY_STARTED=1
|
||||
else
|
||||
echo "[INFO] USE_INTERNAL_RELAY=1 but RELAY is not local ($RELAY), skipping"
|
||||
fi
|
||||
fi
|
||||
|
||||
start_host
|
||||
|
||||
# Common env vars passed to both vitest runs
|
||||
P2P_ENV=(
|
||||
P2P_TEST_ROOM_ID="$ROOM_ID"
|
||||
P2P_TEST_PASSPHRASE="$PASSPHRASE"
|
||||
P2P_TEST_HOST_PEER_NAME="$HOST_PEER_NAME"
|
||||
P2P_TEST_RELAY="$RELAY"
|
||||
P2P_TEST_APP_ID="$APP_ID"
|
||||
P2P_TEST_HANDOFF_FILE="$HANDOFF_FILE"
|
||||
P2P_TEST_UPLOAD_PEER_NAME="$UPLOAD_PEER_NAME"
|
||||
P2P_TEST_DOWNLOAD_PEER_NAME="$DOWNLOAD_PEER_NAME"
|
||||
P2P_TEST_UPLOAD_VAULT_NAME="$UPLOAD_VAULT_NAME"
|
||||
P2P_TEST_DOWNLOAD_VAULT_NAME="$DOWNLOAD_VAULT_NAME"
|
||||
)
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# ---- Phase 1: Upload ----
|
||||
# Each vitest run gets a fresh browser process, so Trystero's module-level
|
||||
# global state (occupiedRooms, didInit, etc.) is clean for every phase.
|
||||
echo "[INFO] running P2P vitest — upload phase"
|
||||
env "${P2P_ENV[@]}" \
|
||||
npx dotenv-cli -e .env -e .test.env -- \
|
||||
vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-up.test.ts
|
||||
echo "[INFO] upload phase completed"
|
||||
|
||||
# ---- Phase 2: Download ----
|
||||
# Keep the same host process alive so its database handle and relay presence stay stable.
|
||||
echo "[INFO] waiting 5s before download phase..."
|
||||
sleep 5
|
||||
echo "[INFO] running P2P vitest — download phase"
|
||||
env "${P2P_ENV[@]}" \
|
||||
npx dotenv-cli -e .env -e .test.env -- \
|
||||
vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-down.test.ts
|
||||
echo "[INFO] download phase completed"
|
||||
|
||||
echo "[INFO] P2P vitest suite completed"
|
||||
175
test/suitep2p/sync_common_p2p.ts
Normal file
175
test/suitep2p/sync_common_p2p.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* P2P-specific sync helpers.
|
||||
*
|
||||
* Derived from test/suite/sync_common.ts but with all acceptWebPeer() calls
|
||||
* removed. When using a CLI p2p-host with P2P_AutoAcceptingPeers="~.*", peer
|
||||
* acceptance is automatic and no Playwright dialog interaction is needed.
|
||||
*/
|
||||
import { expect } from "vitest";
|
||||
import { waitForIdle, type LiveSyncHarness } from "../harness/harness";
|
||||
import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types";
|
||||
import { delay } from "@/lib/src/common/utils";
|
||||
import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { waitTaskWithFollowups } from "../lib/util";
|
||||
|
||||
const P2P_REPLICATION_TIMEOUT_MS = 180000;
|
||||
|
||||
async function testWebSocketConnection(relayUrl: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[P2P Debug] Testing WebSocket connection to ${relayUrl}`);
|
||||
try {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error(`WebSocket connection to ${relayUrl} timed out`));
|
||||
}, 5000);
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timer);
|
||||
console.log(`[P2P Debug] WebSocket connected to ${relayUrl} successfully`);
|
||||
ws.close();
|
||||
resolve();
|
||||
};
|
||||
ws.onerror = (e) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`[P2P Debug] WebSocket error connecting to ${relayUrl}:`, e);
|
||||
reject(new Error(`WebSocket connection to ${relayUrl} failed`));
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[P2P Debug] WebSocket constructor threw:`, e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForP2PPeers(harness: LiveSyncHarness) {
|
||||
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
|
||||
const maxRetries = 20;
|
||||
let retries = maxRetries;
|
||||
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
|
||||
console.log("[P2P Debug] replicator type:", replicator?.constructor?.name);
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
|
||||
}
|
||||
|
||||
// Ensure P2P is open (getActiveReplicator returns a fresh instance that may not be open yet)
|
||||
if (!replicator.server?.isServing) {
|
||||
console.log("[P2P Debug] P2P not yet serving, calling open()");
|
||||
// Test WebSocket connectivity first
|
||||
const relay = harness.plugin.core.settings.P2P_relays?.split(",")[0]?.trim();
|
||||
if (relay) {
|
||||
try {
|
||||
await testWebSocketConnection(relay);
|
||||
} catch (e) {
|
||||
console.error("[P2P Debug] WebSocket connectivity test failed:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await replicator.open();
|
||||
console.log("[P2P Debug] open() completed, isServing:", replicator.server?.isServing);
|
||||
} catch (e) {
|
||||
console.error("[P2P Debug] open() threw:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for P2P server to actually start (room joined)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const serving = replicator.server?.isServing;
|
||||
console.log(`[P2P Debug] isServing: ${serving} (${i}/30)`);
|
||||
if (serving) break;
|
||||
await delay(500);
|
||||
if (i === 29) throw new Error("P2P server did not start in time.");
|
||||
}
|
||||
|
||||
while (retries-- > 0) {
|
||||
await delay(1000);
|
||||
const peers = replicator.knownAdvertisements;
|
||||
if (peers && peers.length > 0) {
|
||||
console.log("P2P peers connected:", peers);
|
||||
return;
|
||||
}
|
||||
console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`);
|
||||
console.dir(peers);
|
||||
await delay(1000);
|
||||
}
|
||||
console.log("Failed to connect P2P peers after retries");
|
||||
throw new Error("P2P peers did not connect in time.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) {
|
||||
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
|
||||
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
|
||||
}
|
||||
replicator.closeReplication();
|
||||
await delay(30);
|
||||
replicator.closeReplication();
|
||||
await delay(1000);
|
||||
console.log("P2P replicator connections closed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function performReplication(harness: LiveSyncHarness) {
|
||||
await waitForP2PPeers(harness);
|
||||
await delay(500);
|
||||
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
|
||||
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
|
||||
}
|
||||
const knownPeers = replicator.knownAdvertisements;
|
||||
|
||||
const targetPeer = knownPeers.find((peer) => peer.name.startsWith("vault-host")) ?? knownPeers[0] ?? undefined;
|
||||
if (!targetPeer) {
|
||||
throw new Error("No connected P2P peer to synchronise with");
|
||||
}
|
||||
|
||||
const p = replicator.sync(targetPeer.peerId, true);
|
||||
const result = await waitTaskWithFollowups(p, () => Promise.resolve(), P2P_REPLICATION_TIMEOUT_MS, 500);
|
||||
if (result && typeof result === "object" && "error" in result && result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return await harness.plugin.core.services.replication.replicate(true);
|
||||
}
|
||||
|
||||
export async function closeReplication(harness: LiveSyncHarness) {
|
||||
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
|
||||
return await closeP2PReplicatorConnections(harness);
|
||||
}
|
||||
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
console.log("No active replicator to close");
|
||||
return;
|
||||
}
|
||||
await replicator.closeReplication();
|
||||
await waitForIdle(harness);
|
||||
console.log("Replication closed");
|
||||
}
|
||||
|
||||
export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) {
|
||||
// P2P has no remote database to initialise — skip
|
||||
if (setting.remoteType === RemoteTypes.REMOTE_P2P) return;
|
||||
|
||||
if (shouldReset) {
|
||||
await delay(1000);
|
||||
await harness.plugin.core.services.replicator
|
||||
.getActiveReplicator()
|
||||
?.tryResetRemoteDatabase(harness.plugin.core.settings);
|
||||
} else {
|
||||
await harness.plugin.core.services.replicator
|
||||
.getActiveReplicator()
|
||||
?.tryCreateRemoteDatabase(harness.plugin.core.settings);
|
||||
}
|
||||
await harness.plugin.core.services.replicator
|
||||
.getActiveReplicator()
|
||||
?.markRemoteResolved(harness.plugin.core.settings);
|
||||
const status = await harness.plugin.core.services.replicator
|
||||
.getActiveReplicator()
|
||||
?.getRemoteStatus(harness.plugin.core.settings);
|
||||
console.log("Remote status:", status);
|
||||
expect(status).not.toBeFalsy();
|
||||
}
|
||||
165
test/suitep2p/syncp2p.p2p-down.test.ts
Normal file
165
test/suitep2p/syncp2p.p2p-down.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* P2P Replication Tests — Download phase (process 2 of 2)
|
||||
*
|
||||
* Executed by run-p2p-tests.sh as the second vitest process, after the
|
||||
* upload phase has completed and the CLI host holds all the data.
|
||||
*
|
||||
* Reads the handoff JSON written by the upload phase to know which files
|
||||
* to verify, then replicates from the CLI host and checks every file.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from "vitest";
|
||||
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
|
||||
import {
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
RemoteTypes,
|
||||
type FilePath,
|
||||
type ObsidianLiveSyncSettings,
|
||||
AutoAccepting,
|
||||
} from "@/lib/src/common/types";
|
||||
import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile";
|
||||
import { defaultFileOption, testFileRead } from "../suite/db_common";
|
||||
import { delay } from "@/lib/src/common/utils";
|
||||
import { closeReplication, performReplication } from "./sync_common_p2p";
|
||||
import { settingBase } from "../suite/variables";
|
||||
|
||||
const env = (import.meta as any).env;
|
||||
|
||||
const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room";
|
||||
const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass";
|
||||
const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host";
|
||||
const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/";
|
||||
const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p";
|
||||
const DOWNLOAD_PEER_NAME: string = env.P2P_TEST_DOWNLOAD_PEER_NAME ?? `p2p-download-${Date.now()}`;
|
||||
const DOWNLOAD_VAULT_NAME: string = env.P2P_TEST_DOWNLOAD_VAULT_NAME ?? `TestVaultDownload-${Date.now()}`;
|
||||
const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json";
|
||||
|
||||
console.log("[P2P Down] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID);
|
||||
console.log("[P2P Down] HANDOFF_FILE:", HANDOFF_FILE);
|
||||
|
||||
const p2pSetting: ObsidianLiveSyncSettings = {
|
||||
...settingBase,
|
||||
...PREFERRED_SETTING_SELF_HOSTED,
|
||||
showVerboseLog: true,
|
||||
remoteType: RemoteTypes.REMOTE_P2P,
|
||||
encrypt: true,
|
||||
passphrase: PASSPHRASE,
|
||||
usePathObfuscation: true,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: APP_ID,
|
||||
handleFilenameCaseSensitive: false,
|
||||
P2P_AutoAccepting: AutoAccepting.ALL,
|
||||
P2P_AutoBroadcast: true,
|
||||
P2P_AutoStart: true,
|
||||
P2P_passphrase: PASSPHRASE,
|
||||
P2P_roomID: ROOM_ID,
|
||||
P2P_relays: RELAY,
|
||||
P2P_AutoAcceptingPeers: "~.*",
|
||||
P2P_SyncOnReplication: HOST_PEER_NAME,
|
||||
};
|
||||
|
||||
const fileOptions = defaultFileOption;
|
||||
const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`;
|
||||
|
||||
/** Read the handoff JSON produced by the upload phase. */
|
||||
async function readHandoff(): Promise<{ fileSizeMd: number[]; fileSizeBins: number[] }> {
|
||||
const { commands } = await import("@vitest/browser/context");
|
||||
const raw = await commands.readHandoffFile(HANDOFF_FILE);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
describe("P2P Replication — Download", () => {
|
||||
let harnessDownload: LiveSyncHarness;
|
||||
let fileSizeMd: number[] = [];
|
||||
let fileSizeBins: number[] = [];
|
||||
|
||||
const downloadSetting: ObsidianLiveSyncSettings = {
|
||||
...p2pSetting,
|
||||
P2P_DevicePeerName: DOWNLOAD_PEER_NAME,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await DummyFileSourceInisialised;
|
||||
|
||||
const handoff = await readHandoff();
|
||||
fileSizeMd = handoff.fileSizeMd;
|
||||
fileSizeBins = handoff.fileSizeBins;
|
||||
console.log("[P2P Down] handoff loaded — md sizes:", fileSizeMd, "bin sizes:", fileSizeBins);
|
||||
|
||||
const vaultName = DOWNLOAD_VAULT_NAME;
|
||||
console.log(`[P2P Down] BeforeAll - Vault: ${vaultName}`);
|
||||
console.log(`[P2P Down] Peer name: ${DOWNLOAD_PEER_NAME}`);
|
||||
harnessDownload = await generateHarness(vaultName, downloadSetting);
|
||||
await waitForReady(harnessDownload);
|
||||
|
||||
await performReplication(harnessDownload);
|
||||
await waitForIdle(harnessDownload);
|
||||
await delay(1000);
|
||||
await performReplication(harnessDownload);
|
||||
await waitForIdle(harnessDownload);
|
||||
await delay(3000);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await performReplication(harnessDownload);
|
||||
await waitForIdle(harnessDownload);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeReplication(harnessDownload);
|
||||
await harnessDownload.dispose();
|
||||
await delay(1000);
|
||||
});
|
||||
|
||||
it("should be instantiated and defined", () => {
|
||||
expect(harnessDownload.plugin).toBeDefined();
|
||||
expect(harnessDownload.plugin.app).toBe(harnessDownload.app);
|
||||
});
|
||||
|
||||
it("should have services initialized", () => {
|
||||
expect(harnessDownload.plugin.core.services).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have local database initialized", () => {
|
||||
expect(harnessDownload.plugin.core.localDatabase).toBeDefined();
|
||||
expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true);
|
||||
});
|
||||
|
||||
it("should have synchronised the stored file", async () => {
|
||||
await testFileRead(harnessDownload, nameFile("store", "md", 0), "Hello, World!", fileOptions);
|
||||
});
|
||||
|
||||
it("should have synchronised files with different content", async () => {
|
||||
await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions);
|
||||
await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions);
|
||||
await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions);
|
||||
});
|
||||
|
||||
// NOTE: test.each cannot use variables populated in beforeAll, so we use
|
||||
// a single it() that iterates over the sizes loaded from the handoff file.
|
||||
it("should have synchronised all large md files", async () => {
|
||||
for (const size of fileSizeMd) {
|
||||
const content = Array.from(generateFile(size)).join("");
|
||||
const path = nameFile("large", "md", size);
|
||||
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
|
||||
if (isTooLarge) {
|
||||
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
|
||||
expect(entry).toBe(false);
|
||||
} else {
|
||||
await testFileRead(harnessDownload, path, content, fileOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should have synchronised all binary files", async () => {
|
||||
for (const size of fileSizeBins) {
|
||||
const path = nameFile("binary", "bin", size);
|
||||
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
|
||||
if (isTooLarge) {
|
||||
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
|
||||
expect(entry).toBe(false);
|
||||
} else {
|
||||
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
|
||||
await testFileRead(harnessDownload, path, content, fileOptions);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
161
test/suitep2p/syncp2p.p2p-up.test.ts
Normal file
161
test/suitep2p/syncp2p.p2p-up.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* P2P Replication Tests — Upload phase (process 1 of 2)
|
||||
*
|
||||
* Executed by run-p2p-tests.sh as the first vitest process.
|
||||
* Writes files into the local DB, replicates them to the CLI host,
|
||||
* then writes a handoff JSON so the download process knows what to verify.
|
||||
*
|
||||
* Trystero has module-level global state (occupiedRooms, didInit, etc.)
|
||||
* that cannot be safely reused across upload→download within the same
|
||||
* browser process. Running upload and download as separate vitest
|
||||
* invocations gives each phase a fresh browser context.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, test } from "vitest";
|
||||
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
|
||||
import {
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
RemoteTypes,
|
||||
type ObsidianLiveSyncSettings,
|
||||
AutoAccepting,
|
||||
} from "@/lib/src/common/types";
|
||||
import {
|
||||
DummyFileSourceInisialised,
|
||||
FILE_SIZE_BINS,
|
||||
FILE_SIZE_MD,
|
||||
generateBinaryFile,
|
||||
generateFile,
|
||||
} from "../utils/dummyfile";
|
||||
import { checkStoredFileInDB, defaultFileOption, testFileWrite } from "../suite/db_common";
|
||||
import { delay } from "@/lib/src/common/utils";
|
||||
import { closeReplication, performReplication } from "./sync_common_p2p";
|
||||
import { settingBase } from "../suite/variables";
|
||||
|
||||
const env = (import.meta as any).env;
|
||||
|
||||
const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room";
|
||||
const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass";
|
||||
const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host";
|
||||
const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/";
|
||||
const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p";
|
||||
const UPLOAD_PEER_NAME: string = env.P2P_TEST_UPLOAD_PEER_NAME ?? `p2p-upload-${Date.now()}`;
|
||||
const UPLOAD_VAULT_NAME: string = env.P2P_TEST_UPLOAD_VAULT_NAME ?? `TestVaultUpload-${Date.now()}`;
|
||||
// Path written by run-p2p-tests.sh; the download phase reads it back.
|
||||
const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json";
|
||||
|
||||
console.log("[P2P Up] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID);
|
||||
console.log("[P2P Up] HANDOFF_FILE:", HANDOFF_FILE);
|
||||
|
||||
const p2pSetting: ObsidianLiveSyncSettings = {
|
||||
...settingBase,
|
||||
...PREFERRED_SETTING_SELF_HOSTED,
|
||||
showVerboseLog: true,
|
||||
remoteType: RemoteTypes.REMOTE_P2P,
|
||||
encrypt: true,
|
||||
passphrase: PASSPHRASE,
|
||||
usePathObfuscation: true,
|
||||
P2P_Enabled: true,
|
||||
P2P_AppID: APP_ID,
|
||||
handleFilenameCaseSensitive: false,
|
||||
P2P_AutoAccepting: AutoAccepting.ALL,
|
||||
P2P_AutoBroadcast: true,
|
||||
P2P_AutoStart: true,
|
||||
P2P_passphrase: PASSPHRASE,
|
||||
P2P_roomID: ROOM_ID,
|
||||
P2P_relays: RELAY,
|
||||
P2P_AutoAcceptingPeers: "~.*",
|
||||
P2P_SyncOnReplication: HOST_PEER_NAME,
|
||||
};
|
||||
|
||||
const fileOptions = defaultFileOption;
|
||||
const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`;
|
||||
|
||||
/** Write the handoff JSON so the download phase knows which files to verify. */
|
||||
async function writeHandoff() {
|
||||
const handoff = {
|
||||
fileSizeMd: FILE_SIZE_MD,
|
||||
fileSizeBins: FILE_SIZE_BINS,
|
||||
};
|
||||
const { commands } = await import("@vitest/browser/context");
|
||||
await commands.writeHandoffFile(HANDOFF_FILE, JSON.stringify(handoff));
|
||||
console.log("[P2P Up] handoff written to", HANDOFF_FILE);
|
||||
}
|
||||
|
||||
describe("P2P Replication — Upload", () => {
|
||||
let harnessUpload: LiveSyncHarness;
|
||||
|
||||
const uploadSetting: ObsidianLiveSyncSettings = {
|
||||
...p2pSetting,
|
||||
P2P_DevicePeerName: UPLOAD_PEER_NAME,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await DummyFileSourceInisialised;
|
||||
const vaultName = UPLOAD_VAULT_NAME;
|
||||
console.log(`[P2P Up] BeforeAll - Vault: ${vaultName}`);
|
||||
console.log(`[P2P Up] Peer name: ${UPLOAD_PEER_NAME}`);
|
||||
harnessUpload = await generateHarness(vaultName, uploadSetting);
|
||||
await waitForReady(harnessUpload);
|
||||
expect(harnessUpload.plugin).toBeDefined();
|
||||
await waitForIdle(harnessUpload);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeReplication(harnessUpload);
|
||||
await harnessUpload.dispose();
|
||||
await delay(1000);
|
||||
});
|
||||
|
||||
it("should be instantiated and defined", () => {
|
||||
expect(harnessUpload.plugin).toBeDefined();
|
||||
expect(harnessUpload.plugin.app).toBe(harnessUpload.app);
|
||||
});
|
||||
|
||||
it("should have services initialized", () => {
|
||||
expect(harnessUpload.plugin.core.services).toBeDefined();
|
||||
});
|
||||
|
||||
it("should have local database initialized", () => {
|
||||
expect(harnessUpload.plugin.core.localDatabase).toBeDefined();
|
||||
expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a file", async () => {
|
||||
await testFileWrite(harnessUpload, nameFile("store", "md", 0), "Hello, World!", false, fileOptions);
|
||||
});
|
||||
|
||||
it("should create several files with different content", async () => {
|
||||
await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions);
|
||||
await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions);
|
||||
await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions);
|
||||
});
|
||||
|
||||
test.each(FILE_SIZE_MD)("should create large md file of size %i bytes", async (size) => {
|
||||
const content = Array.from(generateFile(size)).join("");
|
||||
const path = nameFile("large", "md", size);
|
||||
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
|
||||
if (isTooLarge) {
|
||||
expect(true).toBe(true);
|
||||
} else {
|
||||
await testFileWrite(harnessUpload, path, content, false, fileOptions);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(FILE_SIZE_BINS)("should create binary file of size %i bytes", async (size) => {
|
||||
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
|
||||
const path = nameFile("binary", "bin", size);
|
||||
await testFileWrite(harnessUpload, path, content, true, fileOptions);
|
||||
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
|
||||
if (!isTooLarge) {
|
||||
await checkStoredFileInDB(harnessUpload, path, content, fileOptions);
|
||||
}
|
||||
});
|
||||
|
||||
it("should replicate uploads to CLI host", async () => {
|
||||
await performReplication(harnessUpload);
|
||||
await performReplication(harnessUpload);
|
||||
});
|
||||
|
||||
it("should write handoff file for download phase", async () => {
|
||||
await writeHandoff();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user