From 6c69547cef605080ea83e4c94868bc98b9e1b5e7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 16 Mar 2026 00:48:22 +0900 Subject: [PATCH] ### 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. --- .github/workflows/harness-ci.yml | 2 +- package.json | 3 +- src/apps/cli/main.ts | 3 +- src/apps/cli/package.json | 1 + .../test-p2p-three-nodes-conflict-linux.sh | 0 .../test-p2p-upload-download-repro-linux.sh | 228 ++++++++++++++++++ src/lib | 2 +- src/modules/core/ModuleReplicatorP2P.ts | 23 +- test/lib/commands.ts | 20 ++ test/suitep2p/run-p2p-tests.sh | 194 +++++++++++++++ test/suitep2p/sync_common_p2p.ts | 175 ++++++++++++++ test/suitep2p/syncp2p.p2p-down.test.ts | 165 +++++++++++++ test/suitep2p/syncp2p.p2p-up.test.ts | 161 +++++++++++++ vitest.config.p2p.ts | 82 +++++++ 14 files changed, 1039 insertions(+), 20 deletions(-) mode change 100644 => 100755 src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh create mode 100644 src/apps/cli/test/test-p2p-upload-download-repro-linux.sh create mode 100755 test/suitep2p/run-p2p-tests.sh create mode 100644 test/suitep2p/sync_common_p2p.ts create mode 100644 test/suitep2p/syncp2p.p2p-down.test.ts create mode 100644 test/suitep2p/syncp2p.p2p-up.test.ts create mode 100644 vitest.config.p2p.ts diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml index 9b0231f..c9fff4a 100644 --- a/.github/workflows/harness-ci.yml +++ b/.github/workflows/harness-ci.yml @@ -56,7 +56,7 @@ jobs: if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }} env: CI: true - run: npm run test suitep2p/ + run: npm run test:p2p - name: Stop test services (CouchDB) run: npm run test:docker-couchdb:stop if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }} diff --git a/package.json b/package.json index c6884e1..8e1d750 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down ; npm run test:docker-p2p:down", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:stop": "npm run test:docker-all:down", - "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop" + "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", + "test:p2p": "bash test/suitep2p/run-p2p-tests.sh" }, "keywords": [], "author": "vorotamoroz", diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index d153c88..6316181 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -23,6 +23,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { LiveSyncBaseCore } from "../../LiveSyncBaseCore"; +import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types"; import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; @@ -352,7 +353,7 @@ export async function main() { (core: LiveSyncBaseCore, serviceHub: InjectableServiceHub) => { return initialiseServiceModulesCLI(vaultPath, core, serviceHub); }, - () => [], // No extra modules + (core) => [new ModuleReplicatorP2P(core)], // Register P2P replicator for CLI (useP2PReplicator is not used here) () => [], // No add-ons (core) => { // Add target filter to prevent internal files are handled diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 17669f3..30029bb 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -19,6 +19,7 @@ "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", "test:e2e:p2p": "bash test/test-p2p-three-nodes-conflict-linux.sh", + "test:e2e:p2p-upload-download-repro": "bash test/test-p2p-upload-download-repro-linux.sh", "test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh", "test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh", "test:e2e:p2p-peers:local-relay": "bash test/test-p2p-peers-local-relay.sh", diff --git a/src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh b/src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh old mode 100644 new mode 100755 diff --git a/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh b/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh new file mode 100644 index 0000000..2756c4a --- /dev/null +++ b/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +cd "$CLI_DIR" +source "$SCRIPT_DIR/test-helpers.sh" +display_test_info + +RUN_BUILD="${RUN_BUILD:-1}" +KEEP_TEST_DATA="${KEEP_TEST_DATA:-1}" +VERBOSE_TEST_LOGGING="${VERBOSE_TEST_LOGGING:-0}" + +RELAY="${RELAY:-ws://localhost:4000/}" +USE_INTERNAL_RELAY="${USE_INTERNAL_RELAY:-1}" +APP_ID="${APP_ID:-self-hosted-livesync-cli-tests}" +PEERS_TIMEOUT="${PEERS_TIMEOUT:-20}" +SYNC_TIMEOUT="${SYNC_TIMEOUT:-240}" + +ROOM_ID="p2p-room-$(date +%s)-$RANDOM-$RANDOM" +PASSPHRASE="p2p-pass-$(date +%s)-$RANDOM-$RANDOM" + +HOST_PEER_NAME="p2p-cli-host" +UPLOAD_PEER_NAME="p2p-cli-upload-$(date +%s)-$RANDOM" +DOWNLOAD_PEER_NAME="p2p-cli-download-$(date +%s)-$RANDOM" + +cli_test_init_cli_cmd + +if [[ "$RUN_BUILD" == "1" ]]; then + echo "[INFO] building CLI" + npm run build +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-p2p-upload-download.XXXXXX")" +VAULT_HOST="$WORK_DIR/vault-host" +VAULT_UP="$WORK_DIR/vault-up" +VAULT_DOWN="$WORK_DIR/vault-down" +SETTINGS_HOST="$WORK_DIR/settings-host.json" +SETTINGS_UP="$WORK_DIR/settings-up.json" +SETTINGS_DOWN="$WORK_DIR/settings-down.json" +HOST_LOG="$WORK_DIR/p2p-host.log" +mkdir -p "$VAULT_HOST" "$VAULT_UP" "$VAULT_DOWN" + +cleanup() { + local exit_code=$? + if [[ -n "${HOST_PID:-}" ]] && kill -0 "$HOST_PID" >/dev/null 2>&1; then + 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 + +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 local relay startup" + fi +fi + +run_cli_host() { + run_cli "$VAULT_HOST" --settings "$SETTINGS_HOST" "$@" +} + +run_cli_up() { + run_cli "$VAULT_UP" --settings "$SETTINGS_UP" "$@" +} + +run_cli_down() { + run_cli "$VAULT_DOWN" --settings "$SETTINGS_DOWN" "$@" +} + +apply_p2p_test_tweaks() { + local settings_file="$1" + local device_name="$2" + SETTINGS_FILE="$settings_file" DEVICE_NAME="$device_name" PASSPHRASE_VAL="$PASSPHRASE" node <<'NODE' +const fs = require("node:fs"); +const settingsPath = process.env.SETTINGS_FILE; +const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + +data.remoteType = "ONLY_P2P"; +data.encrypt = true; +data.passphrase = process.env.PASSPHRASE_VAL; +data.usePathObfuscation = true; +data.handleFilenameCaseSensitive = false; +data.customChunkSize = 50; +data.usePluginSyncV2 = true; +data.doNotUseFixedRevisionForChunks = false; +data.P2P_DevicePeerName = process.env.DEVICE_NAME; +data.isConfigured = true; + +fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2), "utf-8"); +NODE +} + +discover_peer_id() { + local side="$1" + local output + local peer_id + if [[ "$side" == "up" ]]; then + output="$(run_cli_up p2p-peers "$PEERS_TIMEOUT")" + else + output="$(run_cli_down p2p-peers "$PEERS_TIMEOUT")" + fi + peer_id="$(awk -F $'\t' 'NF>=3 && $1=="[peer]" {print $2; exit}' <<< "$output")" + if [[ -z "$peer_id" ]]; then + echo "[FAIL] ${side} could not discover any peer" >&2 + echo "[FAIL] peers output:" >&2 + echo "$output" >&2 + return 1 + fi + echo "$peer_id" +} + +echo "[INFO] preparing settings" +echo "[INFO] relay=$RELAY room=$ROOM_ID app=$APP_ID" +cli_test_init_settings_file "$SETTINGS_HOST" +cli_test_init_settings_file "$SETTINGS_UP" +cli_test_init_settings_file "$SETTINGS_DOWN" +cli_test_apply_p2p_settings "$SETTINGS_HOST" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*" +cli_test_apply_p2p_settings "$SETTINGS_UP" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*" +cli_test_apply_p2p_settings "$SETTINGS_DOWN" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*" +apply_p2p_test_tweaks "$SETTINGS_HOST" "$HOST_PEER_NAME" +apply_p2p_test_tweaks "$SETTINGS_UP" "$UPLOAD_PEER_NAME" +apply_p2p_test_tweaks "$SETTINGS_DOWN" "$DOWNLOAD_PEER_NAME" + +echo "[CASE] start p2p-host" +run_cli_host p2p-host >"$HOST_LOG" 2>&1 & +HOST_PID=$! +for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do + if grep -Fq "P2P host is running" "$HOST_LOG"; then + break + fi + sleep 1 +done +if ! grep -Fq "P2P host is running" "$HOST_LOG"; then + echo "[FAIL] p2p-host did not become ready" >&2 + cat "$HOST_LOG" >&2 + exit 1 +fi +echo "[PASS] p2p-host started" + +echo "[CASE] upload peer discovers host" +HOST_PEER_ID_FOR_UP="$(discover_peer_id up)" +echo "[PASS] upload peer discovered host: $HOST_PEER_ID_FOR_UP" + +echo "[CASE] upload phase writes source files" +STORE_TEXT="$WORK_DIR/store-file.md" +DIFF_A_TEXT="$WORK_DIR/test-diff-1.md" +DIFF_B_TEXT="$WORK_DIR/test-diff-2.md" +DIFF_C_TEXT="$WORK_DIR/test-diff-3.md" +printf 'Hello, World!\n' > "$STORE_TEXT" +printf 'Content A\n' > "$DIFF_A_TEXT" +printf 'Content B\n' > "$DIFF_B_TEXT" +printf 'Content C\n' > "$DIFF_C_TEXT" +run_cli_up push "$STORE_TEXT" p2p/store-file.md >/dev/null +run_cli_up push "$DIFF_A_TEXT" p2p/test-diff-1.md >/dev/null +run_cli_up push "$DIFF_B_TEXT" p2p/test-diff-2.md >/dev/null +run_cli_up push "$DIFF_C_TEXT" p2p/test-diff-3.md >/dev/null + +LARGE_TXT_100K="$WORK_DIR/large-100k.txt" +LARGE_TXT_1M="$WORK_DIR/large-1m.txt" +head -c 100000 /dev/zero | tr '\0' 'a' > "$LARGE_TXT_100K" +head -c 1000000 /dev/zero | tr '\0' 'b' > "$LARGE_TXT_1M" +run_cli_up push "$LARGE_TXT_100K" p2p/large-100000.md >/dev/null +run_cli_up push "$LARGE_TXT_1M" p2p/large-1000000.md >/dev/null + +BINARY_100K="$WORK_DIR/binary-100k.bin" +BINARY_5M="$WORK_DIR/binary-5m.bin" +head -c 100000 /dev/urandom > "$BINARY_100K" +head -c 5000000 /dev/urandom > "$BINARY_5M" +run_cli_up push "$BINARY_100K" p2p/binary-100000.bin >/dev/null +run_cli_up push "$BINARY_5M" p2p/binary-5000000.bin >/dev/null +echo "[PASS] upload source files prepared" + +echo "[CASE] upload phase syncs to host" +run_cli_up p2p-sync "$HOST_PEER_ID_FOR_UP" "$SYNC_TIMEOUT" >/dev/null +run_cli_up p2p-sync "$HOST_PEER_ID_FOR_UP" "$SYNC_TIMEOUT" >/dev/null +echo "[PASS] upload phase synced" + +echo "[CASE] download peer discovers host" +HOST_PEER_ID_FOR_DOWN="$(discover_peer_id down)" +echo "[PASS] download peer discovered host: $HOST_PEER_ID_FOR_DOWN" + +echo "[CASE] download phase syncs from host" +run_cli_down p2p-sync "$HOST_PEER_ID_FOR_DOWN" "$SYNC_TIMEOUT" >/dev/null +run_cli_down p2p-sync "$HOST_PEER_ID_FOR_DOWN" "$SYNC_TIMEOUT" >/dev/null +echo "[PASS] download phase synced" + +echo "[CASE] verify text files on download peer" +DOWN_STORE_TEXT="$WORK_DIR/down-store-file.md" +DOWN_DIFF_A_TEXT="$WORK_DIR/down-test-diff-1.md" +DOWN_DIFF_B_TEXT="$WORK_DIR/down-test-diff-2.md" +DOWN_DIFF_C_TEXT="$WORK_DIR/down-test-diff-3.md" +run_cli_down pull p2p/store-file.md "$DOWN_STORE_TEXT" >/dev/null +run_cli_down pull p2p/test-diff-1.md "$DOWN_DIFF_A_TEXT" >/dev/null +run_cli_down pull p2p/test-diff-2.md "$DOWN_DIFF_B_TEXT" >/dev/null +run_cli_down pull p2p/test-diff-3.md "$DOWN_DIFF_C_TEXT" >/dev/null +cmp -s "$STORE_TEXT" "$DOWN_STORE_TEXT" || { echo "[FAIL] store-file mismatch" >&2; exit 1; } +cmp -s "$DIFF_A_TEXT" "$DOWN_DIFF_A_TEXT" || { echo "[FAIL] test-diff-1 mismatch" >&2; exit 1; } +cmp -s "$DIFF_B_TEXT" "$DOWN_DIFF_B_TEXT" || { echo "[FAIL] test-diff-2 mismatch" >&2; exit 1; } +cmp -s "$DIFF_C_TEXT" "$DOWN_DIFF_C_TEXT" || { echo "[FAIL] test-diff-3 mismatch" >&2; exit 1; } + +echo "[CASE] verify pushed files on download peer" +DOWN_LARGE_100K="$WORK_DIR/down-large-100k.txt" +DOWN_LARGE_1M="$WORK_DIR/down-large-1m.txt" +DOWN_BINARY_100K="$WORK_DIR/down-binary-100k.bin" +DOWN_BINARY_5M="$WORK_DIR/down-binary-5m.bin" +run_cli_down pull p2p/large-100000.md "$DOWN_LARGE_100K" >/dev/null +run_cli_down pull p2p/large-1000000.md "$DOWN_LARGE_1M" >/dev/null +run_cli_down pull p2p/binary-100000.bin "$DOWN_BINARY_100K" >/dev/null +run_cli_down pull p2p/binary-5000000.bin "$DOWN_BINARY_5M" >/dev/null +cmp -s "$LARGE_TXT_100K" "$DOWN_LARGE_100K" || { echo "[FAIL] large-100000 mismatch" >&2; exit 1; } +cmp -s "$LARGE_TXT_1M" "$DOWN_LARGE_1M" || { echo "[FAIL] large-1000000 mismatch" >&2; exit 1; } +cmp -s "$BINARY_100K" "$DOWN_BINARY_100K" || { echo "[FAIL] binary-100000 mismatch" >&2; exit 1; } +cmp -s "$BINARY_5M" "$DOWN_BINARY_5M" || { echo "[FAIL] binary-5000000 mismatch" >&2; exit 1; } + +echo "[PASS] CLI P2P upload/download reproduction scenario completed" diff --git a/src/lib b/src/lib index f119555..9145013 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit f1195550d7e254cd6202f1e13f94830d8a383efb +Subproject commit 9145013efa054f6e6f388bff9f405ad42eb18b92 diff --git a/src/modules/core/ModuleReplicatorP2P.ts b/src/modules/core/ModuleReplicatorP2P.ts index 1a40eca..7f59794 100644 --- a/src/modules/core/ModuleReplicatorP2P.ts +++ b/src/modules/core/ModuleReplicatorP2P.ts @@ -4,6 +4,13 @@ import { AbstractModule } from "../AbstractModule"; import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator"; import type { LiveSyncCore } from "../../main"; +// Note: +// This module registers only the `getNewReplicator` handler for the P2P replicator. +// `useP2PReplicator` (see P2PReplicatorCore.ts) already registers the same `getNewReplicator` +// handler internally, so this module is redundant in environments that call `useP2PReplicator`. +// Register this module only in environments that do NOT use `useP2PReplicator` (e.g. CLI). +// In other words: just resolving `getNewReplicator` via this module is all that is needed +// to satisfy what `useP2PReplicator` requires from the replicator service. export class ModuleReplicatorP2P extends AbstractModule { _anyNewReplicator(settingOverride: Partial = {}): Promise { const settings = { ...this.settings, ...settingOverride }; @@ -12,23 +19,7 @@ export class ModuleReplicatorP2P extends AbstractModule { } return Promise.resolve(false); } - _everyAfterResumeProcess(): Promise { - if (this.settings.remoteType == REMOTE_P2P) { - // // If LiveSync enabled, open replication - // if (this.settings.liveSync) { - // fireAndForget(() => this.core.replicator.openReplication(this.settings, true, false, false)); - // } - // // If sync on start enabled, open replication - // if (!this.settings.liveSync && this.settings.syncOnStart) { - // // Possibly ok as if only share the result - // fireAndForget(() => this.core.replicator.openReplication(this.settings, false, false, false)); - // } - } - - return Promise.resolve(true); - } override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this)); - services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this)); } } diff --git a/test/lib/commands.ts b/test/lib/commands.ts index b2fb222..762b5c0 100644 --- a/test/lib/commands.ts +++ b/test/lib/commands.ts @@ -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 => { + 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; closeWebPeer: () => Promise; acceptWebPeer: () => Promise; + writeHandoffFile: (filePath: string, content: string) => Promise; + readHandoffFile: (filePath: string) => Promise; } } diff --git a/test/suitep2p/run-p2p-tests.sh b/test/suitep2p/run-p2p-tests.sh new file mode 100755 index 0000000..4d8a50c --- /dev/null +++ b/test/suitep2p/run-p2p-tests.sh @@ -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" diff --git a/test/suitep2p/sync_common_p2p.ts b/test/suitep2p/sync_common_p2p.ts new file mode 100644 index 0000000..5300989 --- /dev/null +++ b/test/suitep2p/sync_common_p2p.ts @@ -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 { + 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(); +} diff --git a/test/suitep2p/syncp2p.p2p-down.test.ts b/test/suitep2p/syncp2p.p2p-down.test.ts new file mode 100644 index 0000000..7f3b77f --- /dev/null +++ b/test/suitep2p/syncp2p.p2p-down.test.ts @@ -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); + } + } + }); +}); diff --git a/test/suitep2p/syncp2p.p2p-up.test.ts b/test/suitep2p/syncp2p.p2p-up.test.ts new file mode 100644 index 0000000..7c463eb --- /dev/null +++ b/test/suitep2p/syncp2p.p2p-up.test.ts @@ -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(); + }); +}); diff --git a/vitest.config.p2p.ts b/vitest.config.p2p.ts new file mode 100644 index 0000000..f66ed09 --- /dev/null +++ b/vitest.config.p2p.ts @@ -0,0 +1,82 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; +import viteConfig from "./vitest.config.common"; +import path from "path"; +import dotenv from "dotenv"; +import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands"; + +const defEnv = dotenv.config({ path: ".env" }).parsed; +const testEnv = dotenv.config({ path: ".test.env" }).parsed; +// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence) +const p2pEnv: Record = {}; +if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID; +if (process.env.P2P_TEST_PASSPHRASE) p2pEnv.P2P_TEST_PASSPHRASE = process.env.P2P_TEST_PASSPHRASE; +if (process.env.P2P_TEST_HOST_PEER_NAME) p2pEnv.P2P_TEST_HOST_PEER_NAME = process.env.P2P_TEST_HOST_PEER_NAME; +if (process.env.P2P_TEST_RELAY) p2pEnv.P2P_TEST_RELAY = process.env.P2P_TEST_RELAY; +if (process.env.P2P_TEST_APP_ID) p2pEnv.P2P_TEST_APP_ID = process.env.P2P_TEST_APP_ID; +if (process.env.P2P_TEST_HANDOFF_FILE) p2pEnv.P2P_TEST_HANDOFF_FILE = process.env.P2P_TEST_HANDOFF_FILE; +const env = Object.assign({}, defEnv, testEnv, p2pEnv); +const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; +const enableUI = env?.ENABLE_UI === "true"; +const headless = !debuggerEnabled && !enableUI; + +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"), + }, + }, + test: { + env: env, + testTimeout: 240000, + hookTimeout: 240000, + fileParallelism: false, + isolate: true, + watch: false, + // Run all CLI-host P2P test files (*.p2p.test.ts, *.p2p-up.test.ts, *.p2p-down.test.ts) + include: ["test/suitep2p/**/*.p2p*.test.ts"], + browser: { + isolate: true, + // Only grantClipboardPermissions is needed; no openWebPeer/acceptWebPeer + commands: { + grantClipboardPermissions, + writeHandoffFile, + readHandoffFile, + }, + provider: playwright({ + launchOptions: { + args: [ + "--js-flags=--expose-gc", + "--allow-insecure-localhost", + "--disable-web-security", + "--ignore-certificate-errors", + ], + }, + }), + enabled: true, + screenshotFailures: false, + instances: [ + { + execArgv: ["--js-flags=--expose-gc"], + browser: "chromium", + headless, + isolate: true, + inspector: debuggerEnabled ? { waitForDebugger: true, enabled: true } : undefined, + printConsoleTrace: true, + onUnhandledError(error) { + const msg = error.message || ""; + if (msg.includes("Cannot create so many PeerConnections")) { + return false; + } + }, + }, + ], + headless, + fileParallelism: false, + ui: debuggerEnabled || enableUI ? true : false, + }, + }, + }) +);