mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-04-30 12:58:35 +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:
2
.github/workflows/harness-ci.yml
vendored
2
.github/workflows/harness-ci.yml
vendored
@@ -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/' }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
0
src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh
Normal file → Executable file
0
src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh
Normal file → Executable file
228
src/apps/cli/test/test-p2p-upload-download-repro-linux.sh
Normal file
228
src/apps/cli/test/test-p2p-upload-download-repro-linux.sh
Normal file
@@ -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"
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: f1195550d7...9145013efa
@@ -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<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
@@ -12,23 +19,7 @@ export class ModuleReplicatorP2P extends AbstractModule {
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
_everyAfterResumeProcess(): Promise<boolean> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
82
vitest.config.p2p.ts
Normal file
82
vitest.config.p2p.ts
Normal file
@@ -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<string, string> = {};
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user