mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 07:48:18 +00:00
Default new CLI and Obsidian environments to NFC path identity
This commit is contained in:
1 parent
ac502b0261
commit
39cc31c2e8
26 files changed
+1466
-61
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:26-slim AS node26
|
||||
|
||||
FROM mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
|
||||
COPY --from=node26 /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
ENV NODE_OPTIONS=--max-old-space-size=3072
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["node", "--import", "tsx", "test/e2e-obsidian/scripts/p2p-nfc-sync.ts"]
|
||||
@@ -244,6 +244,8 @@ The P2P Setup URI workflow also schedules another Fetch and injects an exception
|
||||
|
||||
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
|
||||
|
||||
`test:e2e:obsidian:p2p-nfc` runs the plain case and the E2EE case using Path Obfuscation and an independent ID key, serially, in an isolated Compose project with a local signalling relay and two real Obsidian processes at a time. Each case seeds an NFD-named source file, fetches it by its NFC spelling, and compares the real local Metadata ID, exact body, and referenced Chunks on both peers. It edits the note from the receiving peer, verifies that the Metadata ID remains stable and that the original revision body remains readable, then fully stops and relaunches the same Vault to check that the ID and updated body remain. A fresh path version 0 peer must show the visible P2P compatibility refusal; the test verifies that it receives no source Metadata and retains its local sentinel. Run `npm run test:e2e:obsidian:p2p-nfc -- --case plain` or `-- --case encrypted` to select one case for debugging. The host wrapper builds the plug-in, uses the existing extracted Obsidian directory and its `obsidian-cli`, mounts the repository read-only, and tears down its unique Compose project after the run. It does not download Obsidian or npm dependencies.
|
||||
|
||||
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
|
||||
|
||||
`test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific.
|
||||
@@ -404,6 +406,10 @@ When these checks pass for the release artefacts, BRAT acceptance can be limited
|
||||
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_NFC_TIMEOUT_MS`: timeout for P2P NFC transfers and the incompatible-peer refusal; default is 120 seconds.
|
||||
- `E2E_OBSIDIAN_P2P_NFC_UI_TIMEOUT_MS`: timeout for P2P NFC status and setup controls; default is 60 seconds.
|
||||
- `E2E_P2P_NFC_CASE`: case selected by the Compose wrapper (`all`, `plain`, or `encrypted`); default is `all`. The `--case` option overrides this value.
|
||||
- `E2E_P2P_NFC_OBSIDIAN_DIR`: extracted Linux Obsidian directory mounted into the Compose runner. The wrapper normally infers it from the discovered `OBSIDIAN_BINARY` and requires the corresponding `obsidian-cli` in the same directory.
|
||||
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
|
||||
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
|
||||
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: livesync-p2p-nfc
|
||||
|
||||
services:
|
||||
p2p-relay:
|
||||
image: ghcr.io/hoytech/strfry:latest
|
||||
entrypoint: ["/app/strfry"]
|
||||
command: ["--config", "/etc/strfry/strfry.conf", "relay"]
|
||||
volumes:
|
||||
- ../../test/fixtures/p2p-relay/strfry.conf:/etc/strfry/strfry.conf:ro
|
||||
tmpfs:
|
||||
- /app/strfry-db:rw,size=256m,mode=1777
|
||||
|
||||
p2p-nfc:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: test/e2e-obsidian/Dockerfile.p2p
|
||||
depends_on:
|
||||
p2p-relay:
|
||||
condition: service_started
|
||||
environment:
|
||||
OBSIDIAN_BINARY: ${E2E_P2P_NFC_OBSIDIAN_BINARY:?Run this scenario through run-compose-p2p-nfc.ts}
|
||||
OBSIDIAN_CLI: ${E2E_P2P_NFC_OBSIDIAN_CLI:?Run this scenario through run-compose-p2p-nfc.ts}
|
||||
E2E_P2P_RELAY_URL: ws://p2p-relay:7777/
|
||||
E2E_P2P_NFC_CASE: ${E2E_P2P_NFC_CASE:-all}
|
||||
E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: "9222"
|
||||
E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT: "9223"
|
||||
E2E_OBSIDIAN_DIAGNOSTICS_DIR: /artifacts
|
||||
volumes:
|
||||
- ../..:/workspace:ro
|
||||
- ${E2E_P2P_NFC_OBSIDIAN_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/opt/obsidian:ro
|
||||
- ${E2E_P2P_NFC_ARTIFACT_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/artifacts
|
||||
working_dir: /workspace
|
||||
mem_limit: 6g
|
||||
cpus: 2
|
||||
init: true
|
||||
ipc: host
|
||||
@@ -0,0 +1,787 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { createNewVaultSettings, P2P_DEFAULT_SETTINGS, PREFERRED_BASE } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
acknowledgeDisabledOptionalFeatures,
|
||||
captureAndStartInitialisation,
|
||||
confirmFastFetch,
|
||||
confirmRebuild,
|
||||
enterSetupURI,
|
||||
finishInitialisation,
|
||||
modalByTitle,
|
||||
resumeCompatibilityReviewIfShown,
|
||||
type SetupArtifact,
|
||||
type SetupCaptureNames,
|
||||
} from "../runner/setupUri.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
waitForLocalDatabaseEntry,
|
||||
waitForLiveSyncCoreReady,
|
||||
type LocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_NFC_UI_TIMEOUT_MS ?? 60000);
|
||||
const syncTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_NFC_TIMEOUT_MS ?? 120000);
|
||||
const pathNfd = "E2E/p2p-nfc/Cafe\u0301/re\u0301sume\u0301.md";
|
||||
const pathNfc = pathNfd.normalize("NFC");
|
||||
const sourceContent = "# NFC identity\n\nThis NFD-named source note keeps one Metadata identity across P2P.\n";
|
||||
const editedContent = "# NFC identity\n\nThis update came from the NFC spelling on the receiving peer.\n";
|
||||
const restartedContent = "# NFC identity\n\nThis edit was sent after the receiving Obsidian process restarted.\n";
|
||||
const sentinelPath = "E2E/p2p-nfc/local-sentinel.md";
|
||||
const sentinelContent = "# Local sentinel\n\nKeep this local file when a mode 0 peer is refused.\n";
|
||||
|
||||
type CaseDefinition = { name: "plain" | "encrypted"; encrypted: boolean };
|
||||
type EntryState = LocalDatabaseEntry & { content: string; chunksPresent: boolean };
|
||||
type VaultFileState = { found: boolean; path: string | null; content: string | null };
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
type SharedSettings = {
|
||||
encrypted: boolean;
|
||||
e2eePassphrase: string;
|
||||
idKey: string;
|
||||
p2pPassphrase: string;
|
||||
relay: string;
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
function sessionPorts(): readonly [number, number] {
|
||||
const first = obsidianRemoteDebuggingPort(process.env);
|
||||
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
|
||||
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
|
||||
throw new Error("Invalid secondary Obsidian remote debugging port: " + second);
|
||||
}
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
function selectedCases(arguments_: readonly string[]): CaseDefinition[] {
|
||||
let requested = process.env.E2E_P2P_NFC_CASE?.trim() || "all";
|
||||
for (let index = 0; index < arguments_.length; index += 1) {
|
||||
const argument = arguments_[index];
|
||||
if (argument === "--help" || argument === "-h") {
|
||||
console.log("Usage: p2p-nfc-sync.ts [--case all|plain|encrypted]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (argument === "--case") {
|
||||
const value = arguments_[index + 1];
|
||||
if (!value) throw new Error("--case requires all, plain, or encrypted.");
|
||||
requested = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("--case=")) {
|
||||
requested = argument.slice("--case=".length);
|
||||
continue;
|
||||
}
|
||||
throw new Error("Unknown P2P NFC scenario argument: " + argument);
|
||||
}
|
||||
const cases: CaseDefinition[] = [
|
||||
{ name: "plain", encrypted: false },
|
||||
{ name: "encrypted", encrypted: true },
|
||||
];
|
||||
if (requested === "all") return cases;
|
||||
const selected = cases.filter((candidate) => candidate.name === requested);
|
||||
if (selected.length === 0) throw new Error("Unsupported P2P NFC case: " + requested);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function createSettings(shared: SharedSettings, pathNormalizationVersion: 0 | 1): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
useEden: false,
|
||||
customChunkSize: 60,
|
||||
readChunksOnline: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
encrypt: shared.encrypted,
|
||||
passphrase: shared.encrypted ? shared.e2eePassphrase : "",
|
||||
usePathObfuscation: shared.encrypted,
|
||||
idDerivationVersion: shared.encrypted ? 1 : 0,
|
||||
idDerivationKey: shared.encrypted ? shared.idKey : "",
|
||||
pathNormalizationVersion,
|
||||
P2P_Enabled: true,
|
||||
P2P_AutoStart: false,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_roomID: shared.roomId,
|
||||
P2P_passphrase: shared.p2pPassphrase,
|
||||
P2P_relays: shared.relay,
|
||||
P2P_AppID: "self-hosted-livesync-real-obsidian-p2p-nfc-e2e",
|
||||
});
|
||||
upsertRemoteConfigurationInPlace(settings, "p2p", {
|
||||
id: "e2e-p2p-nfc",
|
||||
name: "P2P NFC E2E",
|
||||
activate: true,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
async function createSetupArtifact(shared: SharedSettings, pathNormalizationVersion: 0 | 1): Promise<SetupArtifact> {
|
||||
const setupPassphrase = randomBytes(24).toString("base64url");
|
||||
const result = await encodeTimeBoundSetupURI(createSettings(shared, pathNormalizationVersion), setupPassphrase, {
|
||||
mode: "persistent",
|
||||
removeProperties: ["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks"],
|
||||
skipDefaultValue: true,
|
||||
});
|
||||
const setupURI = result.uri.trim();
|
||||
if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) {
|
||||
throw new Error("Commonlib did not create a valid P2P Setup URI.");
|
||||
}
|
||||
return { setupURI, setupPassphrase };
|
||||
}
|
||||
|
||||
async function waitForRelay(relay: string): Promise<void> {
|
||||
const endpoint = new URL(relay);
|
||||
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
|
||||
throw new Error("P2P relay must use ws: or wss:, received " + endpoint.protocol);
|
||||
}
|
||||
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
|
||||
const host = endpoint.hostname;
|
||||
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = connect({ host, port });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("connection timed out"));
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"P2P relay is not ready at " +
|
||||
relay +
|
||||
": " +
|
||||
(lastError instanceof Error ? lastError.message : String(lastError))
|
||||
);
|
||||
}
|
||||
|
||||
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
|
||||
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
port: number
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
env: sessionEnvironment(port),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession | undefined): Promise<void> {
|
||||
if (!session) return;
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function stopSessions(context: RunnerContext): Promise<void> {
|
||||
for (const session of [...context.activeSessions]) await stopSession(context, session);
|
||||
}
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const target = join(vaultPath, path);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, content, "utf8");
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSourceNameIsNfd(vaultPath: string, expectedContent: string): Promise<void> {
|
||||
const nfdFile = join(vaultPath, pathNfd);
|
||||
const nfcFile = join(vaultPath, pathNfc);
|
||||
assertEqual(await readFile(nfdFile, "utf8"), expectedContent, "The source NFD file has unexpected content.");
|
||||
if (pathNfd !== pathNfc && (await pathExists(nfcFile))) {
|
||||
throw new Error("NFC reflection created a second physical source file beside the NFD file.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readVaultFileState(vaultPath: string, requestedPath: string): Promise<VaultFileState> {
|
||||
const candidates = [...new Set([requestedPath, requestedPath.normalize("NFD")])];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const content = await readFile(join(vaultPath, candidate), "utf8");
|
||||
return { found: true, path: candidate, content };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return { found: false, path: null, content: null };
|
||||
}
|
||||
|
||||
async function waitForVaultContent(
|
||||
vaultPath: string,
|
||||
requestedPath: string,
|
||||
expectedContent: string
|
||||
): Promise<VaultFileState> {
|
||||
const deadline = Date.now() + syncTimeoutMs;
|
||||
let lastState: VaultFileState = { found: false, path: null, content: null };
|
||||
while (Date.now() < deadline) {
|
||||
lastState = await readVaultFileState(vaultPath, requestedPath);
|
||||
if (lastState.found && lastState.content === expectedContent) return lastState;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(
|
||||
"Timed out waiting for NFC path content: " + JSON.stringify({ requestedPath, expectedContent, lastState })
|
||||
);
|
||||
}
|
||||
|
||||
async function modifyVaultNote(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
requestedPath: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
return await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const requestedPath=" + JSON.stringify(requestedPath) + ";",
|
||||
"const content=" + JSON.stringify(content) + ";",
|
||||
"const file=app.vault.getFiles().find(candidate=>candidate.path.normalize('NFC')===requestedPath);",
|
||||
"if(!file) throw new Error('Could not find the NFC-equivalent file: '+requestedPath);",
|
||||
"await app.vault.modify(file,content);",
|
||||
"return JSON.stringify(file.path);",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function readEntryState(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
revision?: string
|
||||
): Promise<EntryState> {
|
||||
return await evalObsidianJson<EntryState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const path=" + JSON.stringify(path) + ";",
|
||||
"const revision=" + JSON.stringify(revision ?? null) + ";",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,revision?{rev:revision}:undefined,false,true,true);",
|
||||
"if(!entry||!entry._id||!entry._rev) throw new Error('Metadata was not available at '+path);",
|
||||
"const children=Array.isArray(entry.children)?entry.children:[];",
|
||||
"const chunkRows=children.length?await core.localDatabase.allDocsRaw({keys:children,include_docs:true}):{rows:[]};",
|
||||
"const chunksPresent=children.length>0&&chunkRows.rows.filter(row=>Boolean(row.doc)&&!Boolean(row.value?.deleted)&&!Boolean(row.doc?.deleted)&&!Boolean(row.doc?._deleted)).length===children.length;",
|
||||
"const content=Array.isArray(entry.data)?entry.data.join(''):typeof entry.data==='string'?entry.data:'';",
|
||||
"return JSON.stringify({id:entry._id,rev:entry._rev,path:entry.path,children,content,chunksPresent});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function writeEntryStateWhenReady(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
expectedContent: string
|
||||
): Promise<EntryState> {
|
||||
await waitForLocalDatabaseEntry(cliBinary, environment, path, { timeoutMs: syncTimeoutMs });
|
||||
const deadline = Date.now() + syncTimeoutMs;
|
||||
let state: EntryState;
|
||||
do {
|
||||
state = await readEntryState(cliBinary, environment, path);
|
||||
if (state.content === expectedContent && state.chunksPresent) return state;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
} while (Date.now() < deadline);
|
||||
assertEqual(state.content, expectedContent, "The local Metadata body differs from the expected note bytes.");
|
||||
throw new Error("The Metadata entry is missing one or more referenced Chunk documents.");
|
||||
}
|
||||
|
||||
async function readCurrentSettings(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
expectedIdKey: string
|
||||
): Promise<{
|
||||
pathNormalizationVersion: number;
|
||||
encrypt: boolean;
|
||||
usePathObfuscation: boolean;
|
||||
idDerivationVersion: number;
|
||||
idKeyMatches: boolean;
|
||||
p2pEnabled: boolean;
|
||||
}> {
|
||||
return await evalObsidianJson<{
|
||||
pathNormalizationVersion: number;
|
||||
encrypt: boolean;
|
||||
usePathObfuscation: boolean;
|
||||
idDerivationVersion: number;
|
||||
idKeyMatches: boolean;
|
||||
p2pEnabled: boolean;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"const expectedIdKey=" + JSON.stringify(expectedIdKey) + ";",
|
||||
"return JSON.stringify({pathNormalizationVersion:settings.pathNormalizationVersion??0,encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,idDerivationVersion:settings.idDerivationVersion??0,idKeyMatches:settings.idDerivationKey===expectedIdKey,p2pEnabled:settings.P2P_Enabled});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function assertCaseSettings(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
definition: CaseDefinition,
|
||||
expectedIdKey: string,
|
||||
pathNormalizationVersion: 0 | 1
|
||||
): Promise<void> {
|
||||
const settings = await readCurrentSettings(cliBinary, environment, expectedIdKey);
|
||||
assertEqual(
|
||||
settings.pathNormalizationVersion,
|
||||
pathNormalizationVersion,
|
||||
"The P2P Setup URI lost path identity mode."
|
||||
);
|
||||
assertEqual(settings.encrypt, definition.encrypted, "The P2P Setup URI changed the E2EE setting.");
|
||||
assertEqual(settings.usePathObfuscation, definition.encrypted, "The P2P Setup URI changed Path Obfuscation.");
|
||||
assertEqual(settings.idDerivationVersion, definition.encrypted ? 1 : 0, "The ID derivation version differs.");
|
||||
assertEqual(settings.idKeyMatches, true, "The independent ID key did not reach this device.");
|
||||
assertEqual(settings.p2pEnabled, true, "The P2P Setup URI did not enable P2P.");
|
||||
}
|
||||
|
||||
async function executeCommand(port: number, commandId: string): Promise<void> {
|
||||
const executed = await withObsidianPage(
|
||||
port,
|
||||
async (page) =>
|
||||
await page.evaluate(
|
||||
(id) =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
}
|
||||
).app?.commands?.executeCommandById(id) === true,
|
||||
commandId
|
||||
)
|
||||
);
|
||||
if (!executed) throw new Error("Obsidian command was not available: " + commandId);
|
||||
}
|
||||
|
||||
async function openP2PStatus(port: number): Promise<void> {
|
||||
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const open = pane.getByRole("button", { name: "Open connection", exact: true });
|
||||
if (await open.isVisible()) await open.click({ timeout: uiTimeoutMs });
|
||||
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function reconnectP2PStatus(port: number): Promise<void> {
|
||||
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true });
|
||||
if (await disconnect.isVisible()) await disconnect.click({ timeout: uiTimeoutMs });
|
||||
const open = pane.getByRole("button", { name: "Open connection", exact: true });
|
||||
await open.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await open.click({ timeout: uiTimeoutMs });
|
||||
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDetectedPeer(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function replicateFromStatusPane(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = heading.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await pane.getByRole("button", { name: "Replicate now" }).first().click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptConnectionRequests(ports: readonly number[], shouldStop: () => boolean): Promise<void> {
|
||||
while (!shouldStop()) {
|
||||
for (const port of ports) {
|
||||
const visible = await withObsidianPage(
|
||||
port,
|
||||
async (page) => await modalByTitle(page, "P2P Connection Request").isVisible()
|
||||
).catch(() => false);
|
||||
if (!visible) continue;
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, "P2P Connection Request")
|
||||
.getByRole("button", { name: "Accept", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromFirstPeer(portA: number, portB: number): Promise<void> {
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
let finished = false;
|
||||
const acceptor = acceptConnectionRequests([portA, portB], () => finished);
|
||||
try {
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
await modal
|
||||
.locator(".peer-item")
|
||||
.first()
|
||||
.getByRole("button", { name: "Sync", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
} finally {
|
||||
finished = true;
|
||||
await acceptor;
|
||||
}
|
||||
await withObsidianPage(portB, async (page) => {
|
||||
const modal = modalByTitle(page, "P2P Rebuild");
|
||||
if (await modal.isVisible()) {
|
||||
await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function replicateAndWait(
|
||||
sourcePort: number,
|
||||
receiverPort: number,
|
||||
receiverVaultPath: string,
|
||||
expectedPath: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
let finished = false;
|
||||
const acceptor = acceptConnectionRequests([sourcePort, receiverPort], () => finished);
|
||||
try {
|
||||
await replicateFromStatusPane(sourcePort);
|
||||
await waitForVaultContent(receiverVaultPath, expectedPath, expectedContent);
|
||||
} finally {
|
||||
finished = true;
|
||||
await acceptor;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTweakMismatchNotice(port: number, session: ObsidianLiveSyncSession): Promise<string> {
|
||||
const deadline = Date.now() + syncTimeoutMs;
|
||||
let lastNotices: string[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
lastNotices = await withObsidianPage(
|
||||
port,
|
||||
async (page) => await page.locator(".notice").allTextContents()
|
||||
).catch(() => lastNotices);
|
||||
const mismatch = lastNotices.find((notice) =>
|
||||
notice.includes("The path identity mode does not match this peer")
|
||||
);
|
||||
if (mismatch) return mismatch;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
const output = session.app.output();
|
||||
throw new Error(
|
||||
"The incompatible P2P attempt did not show its tweak refusal. Notices: " +
|
||||
JSON.stringify(lastNotices) +
|
||||
"\nObsidian stdout: " +
|
||||
output.stdout.slice(-2000) +
|
||||
"\nObsidian stderr: " +
|
||||
output.stderr.slice(-2000)
|
||||
);
|
||||
}
|
||||
|
||||
async function readMetadataIds(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): Promise<{ ids: string[]; paths: string[] }> {
|
||||
return await evalObsidianJson<{ ids: string[]; paths: string[] }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
|
||||
"const entries=rows.map(row=>row.doc).filter(doc=>doc&&Array.isArray(doc.children)&&typeof doc._id==='string'&&!doc._deleted);",
|
||||
"return JSON.stringify({ids:entries.map(entry=>entry._id),paths:entries.map(entry=>typeof entry.path==='string'?entry.path.normalize('NFC'):'')});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function runCase(definition: CaseDefinition): Promise<void> {
|
||||
if (pathNfd === pathNfc) throw new Error("The test fixture path did not produce distinct NFC and NFD spellings.");
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error("Could not find obsidian-cli. Checked paths: " + cli.checked.join(", "));
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
|
||||
const [portA, portB] = sessionPorts();
|
||||
const relay = process.env.E2E_P2P_RELAY_URL ?? "ws://p2p-relay:7777/";
|
||||
const shared: SharedSettings = {
|
||||
encrypted: definition.encrypted,
|
||||
e2eePassphrase: randomBytes(24).toString("base64url"),
|
||||
idKey: definition.encrypted ? randomBytes(32).toString("hex") : "",
|
||||
p2pPassphrase: randomBytes(24).toString("base64url"),
|
||||
relay,
|
||||
roomId: "real-obsidian-p2p-nfc-" + randomBytes(12).toString("hex"),
|
||||
};
|
||||
const artifact = await createSetupArtifact(shared, 1);
|
||||
const incompatibleArtifact = await createSetupArtifact(shared, 0);
|
||||
const captures: SetupCaptureNames = {
|
||||
scenario: "p2p-nfc-" + definition.name,
|
||||
guide: "p2p-nfc-" + definition.name,
|
||||
};
|
||||
const vaults: TemporaryVault[] = [];
|
||||
let sessionA: ObsidianLiveSyncSession | undefined;
|
||||
let sessionB: ObsidianLiveSyncSession | undefined;
|
||||
let sessionMode0: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
const vaultA = await createTemporaryVault("obsidian-livesync-p2p-nfc-source-");
|
||||
vaults.push(vaultA);
|
||||
const vaultB = await createTemporaryVault("obsidian-livesync-p2p-nfc-receiver-");
|
||||
vaults.push(vaultB);
|
||||
await writeVaultFile(vaultA.path, pathNfd, sourceContent);
|
||||
|
||||
console.log("P2P NFC case: " + definition.name);
|
||||
console.log("Temporary source and receiving Vaults: " + vaultA.name + ", " + vaultB.name);
|
||||
await waitForRelay(relay);
|
||||
|
||||
sessionA = await startSession(context, vaultA, portA);
|
||||
await enterSetupURI(portA, "new", artifact, captures);
|
||||
await captureAndStartInitialisation(portA, "new", captures);
|
||||
await confirmRebuild(portA, captures);
|
||||
await acknowledgeDisabledOptionalFeatures(portA, captures);
|
||||
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portA);
|
||||
assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P.");
|
||||
await assertCaseSettings(context.cliBinary, sessionA.cliEnv, definition, shared.idKey, 1);
|
||||
await waitForVaultContent(vaultA.path, pathNfc, sourceContent);
|
||||
const sourceEntry = await writeEntryStateWhenReady(context.cliBinary, sessionA.cliEnv, pathNfc, sourceContent);
|
||||
assertEqual(sourceEntry.path.normalize("NFC"), pathNfc, "The source Metadata path is not NFC-equivalent.");
|
||||
await assertSourceNameIsNfd(vaultA.path, sourceContent);
|
||||
|
||||
await openP2PStatus(portA);
|
||||
sessionB = await startSession(context, vaultB, portB);
|
||||
await enterSetupURI(portB, "existing", artifact, captures);
|
||||
await captureAndStartInitialisation(portB, "existing", captures);
|
||||
await confirmFastFetch(portB, captures);
|
||||
await fetchFromFirstPeer(portA, portB);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, pathNfc, { timeoutMs: syncTimeoutMs });
|
||||
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portB);
|
||||
assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P.");
|
||||
await assertCaseSettings(context.cliBinary, sessionB.cliEnv, definition, shared.idKey, 1);
|
||||
await waitForVaultContent(vaultB.path, pathNfc, sourceContent);
|
||||
const receiverEntry = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionB.cliEnv,
|
||||
pathNfc,
|
||||
sourceContent
|
||||
);
|
||||
assertEqual(
|
||||
receiverEntry.id,
|
||||
sourceEntry.id,
|
||||
"The NFD source and NFC peer paths produced different Metadata IDs."
|
||||
);
|
||||
assertEqual(receiverEntry.content, sourceContent, "The NFC peer did not receive the exact source body.");
|
||||
|
||||
await reconnectP2PStatus(portA);
|
||||
await reconnectP2PStatus(portB);
|
||||
await waitForDetectedPeer(portB);
|
||||
await modifyVaultNote(context.cliBinary, sessionB.cliEnv, pathNfc, editedContent);
|
||||
const locallyEditedEntry = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionB.cliEnv,
|
||||
pathNfc,
|
||||
editedContent
|
||||
);
|
||||
assertEqual(locallyEditedEntry.id, sourceEntry.id, "The NFC peer edit changed the Metadata ID.");
|
||||
await replicateAndWait(portB, portA, vaultA.path, pathNfc, editedContent);
|
||||
|
||||
const sourceAfterEdit = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionA.cliEnv,
|
||||
pathNfc,
|
||||
editedContent
|
||||
);
|
||||
assertEqual(sourceAfterEdit.id, sourceEntry.id, "The reverse edit changed the source Metadata ID.");
|
||||
assertEqual(sourceAfterEdit.content, editedContent, "The source did not receive the exact reverse edit.");
|
||||
const originalRevision = await readEntryState(context.cliBinary, sessionA.cliEnv, pathNfc, sourceEntry.rev);
|
||||
assertEqual(originalRevision.rev, sourceEntry.rev, "The original source revision was not retained.");
|
||||
assertEqual(originalRevision.content, sourceContent, "P2P discarded the original revision body.");
|
||||
await assertSourceNameIsNfd(vaultA.path, editedContent);
|
||||
|
||||
await stopSession(context, sessionB);
|
||||
sessionB = await startSession(context, vaultB, portB);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, sessionB.cliEnv);
|
||||
await waitForVaultContent(vaultB.path, pathNfc, editedContent);
|
||||
const afterRestart = await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, pathNfc, editedContent);
|
||||
assertEqual(afterRestart.id, sourceEntry.id, "A full Obsidian restart changed the Metadata ID.");
|
||||
assertEqual(afterRestart.content, editedContent, "A full Obsidian restart changed the local Metadata body.");
|
||||
const originalAfterRestart = await readEntryState(context.cliBinary, sessionB.cliEnv, pathNfc, sourceEntry.rev);
|
||||
assertEqual(
|
||||
originalAfterRestart.content,
|
||||
sourceContent,
|
||||
"A full restart discarded the original revision body."
|
||||
);
|
||||
|
||||
await reconnectP2PStatus(portA);
|
||||
await openP2PStatus(portB);
|
||||
await waitForDetectedPeer(portB);
|
||||
await modifyVaultNote(context.cliBinary, sessionB.cliEnv, pathNfc, restartedContent);
|
||||
await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, pathNfc, restartedContent);
|
||||
await replicateAndWait(portB, portA, vaultA.path, pathNfc, restartedContent);
|
||||
const afterRestartTransfer = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionA.cliEnv,
|
||||
pathNfc,
|
||||
restartedContent
|
||||
);
|
||||
assertEqual(afterRestartTransfer.id, sourceEntry.id, "A post-restart transfer changed the Metadata ID.");
|
||||
await assertSourceNameIsNfd(vaultA.path, restartedContent);
|
||||
|
||||
await stopSession(context, sessionB);
|
||||
sessionB = undefined;
|
||||
const vaultMode0 = await createTemporaryVault("obsidian-livesync-p2p-nfc-mode0-");
|
||||
vaults.push(vaultMode0);
|
||||
await writeVaultFile(vaultMode0.path, sentinelPath, sentinelContent);
|
||||
sessionMode0 = await startSession(context, vaultMode0, portB);
|
||||
await enterSetupURI(portB, "new", incompatibleArtifact, captures);
|
||||
await captureAndStartInitialisation(portB, "new", captures);
|
||||
await confirmRebuild(portB, captures);
|
||||
await acknowledgeDisabledOptionalFeatures(portB, captures);
|
||||
await finishInitialisation(portB, context.cliBinary, sessionMode0.cliEnv);
|
||||
await resumeCompatibilityReviewIfShown(portB);
|
||||
await assertCaseSettings(context.cliBinary, sessionMode0.cliEnv, definition, shared.idKey, 0);
|
||||
const sentinelBefore = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionMode0.cliEnv,
|
||||
sentinelPath,
|
||||
sentinelContent
|
||||
);
|
||||
|
||||
await reconnectP2PStatus(portA);
|
||||
await openP2PStatus(portB);
|
||||
await waitForDetectedPeer(portB);
|
||||
const sourceMetadataBefore = await readMetadataIds(context.cliBinary, sessionA.cliEnv);
|
||||
if (!sourceMetadataBefore.ids.includes(sourceEntry.id)) {
|
||||
throw new Error("The source Metadata entry was absent before the incompatible peer attempted replication.");
|
||||
}
|
||||
let finished = false;
|
||||
const acceptor = acceptConnectionRequests([portA, portB], () => finished);
|
||||
try {
|
||||
await replicateFromStatusPane(portB);
|
||||
await waitForTweakMismatchNotice(portB, sessionMode0);
|
||||
} finally {
|
||||
finished = true;
|
||||
await acceptor;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const mode0MetadataAfter = await readMetadataIds(context.cliBinary, sessionMode0.cliEnv);
|
||||
if (mode0MetadataAfter.ids.includes(sourceEntry.id)) {
|
||||
throw new Error("The mode 0 peer imported source Metadata after the visible P2P tweak refusal.");
|
||||
}
|
||||
if (mode0MetadataAfter.paths.includes(pathNfc)) {
|
||||
throw new Error("The mode 0 peer created source-path Metadata after the visible P2P tweak refusal.");
|
||||
}
|
||||
const absentSourceFile = await readVaultFileState(vaultMode0.path, pathNfc);
|
||||
assertEqual(absentSourceFile.found, false, "The mode 0 peer reflected the rejected source file.");
|
||||
const sentinelAfter = await writeEntryStateWhenReady(
|
||||
context.cliBinary,
|
||||
sessionMode0.cliEnv,
|
||||
sentinelPath,
|
||||
sentinelContent
|
||||
);
|
||||
assertEqual(sentinelAfter.id, sentinelBefore.id, "The failed P2P attempt changed the local sentinel ID.");
|
||||
assertEqual(
|
||||
sentinelAfter.rev,
|
||||
sentinelBefore.rev,
|
||||
"The failed P2P attempt changed the local sentinel revision."
|
||||
);
|
||||
assertEqual(sentinelAfter.content, sentinelContent, "The failed P2P attempt changed the local sentinel body.");
|
||||
|
||||
console.log(
|
||||
"P2P NFC case passed: NFD source identity, NFC lookup, exact Chunk-backed transfer, reverse edit, revision retention, full restart, mode 0 refusal, and sentinel preservation."
|
||||
);
|
||||
} finally {
|
||||
await stopSessions(context).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
for (const vault of vaults) await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cases = selectedCases(process.argv.slice(2));
|
||||
for (const definition of cases) await runCase(definition);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
|
||||
type CommandResult = { code: number; signal: NodeJS.Signals | null };
|
||||
|
||||
function run(command: string, args: readonly string[], env: NodeJS.ProcessEnv = process.env): Promise<CommandResult> {
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(command, [...args], { cwd: process.cwd(), env, stdio: "inherit" });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => resolveResult({ code: code ?? 1, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
function pathInside(root: string, candidate: string): string {
|
||||
const localPath = relative(root, candidate);
|
||||
if (localPath === ".." || localPath.startsWith(".." + sep) || isAbsolute(localPath)) {
|
||||
throw new Error(
|
||||
"The discovered Obsidian executable paths must share one extracted application directory. " +
|
||||
"Set E2E_P2P_NFC_OBSIDIAN_DIR to their common root."
|
||||
);
|
||||
}
|
||||
return localPath.split(sep).join("/");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const repoRoot = resolve(process.cwd());
|
||||
const binary = await realpath(requireObsidianBinary());
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error("Could not find obsidian-cli. Checked paths: " + cli.checked.join(", "));
|
||||
}
|
||||
const cliBinary = await realpath(cli.binary);
|
||||
const appDirectory = await realpath(resolve(process.env.E2E_P2P_NFC_OBSIDIAN_DIR?.trim() || dirname(binary)));
|
||||
if (!(await stat(appDirectory)).isDirectory()) {
|
||||
throw new Error("The selected Obsidian application root is not a directory: " + appDirectory);
|
||||
}
|
||||
const containerBinary = "/opt/obsidian/" + pathInside(appDirectory, binary);
|
||||
const containerCli = "/opt/obsidian/" + pathInside(appDirectory, cliBinary);
|
||||
|
||||
const build = await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]);
|
||||
if (build.code !== 0) {
|
||||
throw new Error("The host plug-in build failed with exit code " + build.code + ".");
|
||||
}
|
||||
|
||||
const artifactsDirectory = await mkdtemp(join(tmpdir(), "livesync-e2e-p2p-nfc-"));
|
||||
const projectName = "livesync-p2p-nfc-" + process.pid + "-" + Date.now() + "-" + randomBytes(4).toString("hex");
|
||||
const composeFile = join(repoRoot, "test/e2e-obsidian/compose.p2p.yml");
|
||||
const composeArgs = ["compose", "--project-name", projectName, "--file", composeFile];
|
||||
const composeEnvironment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
E2E_P2P_NFC_OBSIDIAN_DIR: appDirectory,
|
||||
E2E_P2P_NFC_OBSIDIAN_BINARY: containerBinary,
|
||||
E2E_P2P_NFC_OBSIDIAN_CLI: containerCli,
|
||||
E2E_P2P_NFC_ARTIFACT_DIR: artifactsDirectory,
|
||||
};
|
||||
let exitCode = 0;
|
||||
let failure: unknown;
|
||||
|
||||
try {
|
||||
const result = await run(
|
||||
"docker",
|
||||
[...composeArgs, "run", "--rm", "--build", "p2p-nfc", ...process.argv.slice(2)],
|
||||
composeEnvironment
|
||||
);
|
||||
exitCode = result.code;
|
||||
if (result.signal) exitCode = 1;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
try {
|
||||
const teardown = await run(
|
||||
"docker",
|
||||
[...composeArgs, "down", "--volumes", "--remove-orphans"],
|
||||
composeEnvironment
|
||||
);
|
||||
if (teardown.code !== 0 && exitCode === 0) exitCode = teardown.code;
|
||||
} catch (error) {
|
||||
if (exitCode === 0) {
|
||||
failure = error;
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
await rm(artifactsDirectory, { recursive: true, force: true });
|
||||
} else {
|
||||
console.error("P2P NFC diagnostics were retained at " + artifactsDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) throw failure;
|
||||
if (exitCode !== 0) process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user