import { randomBytes } from "node:crypto"; import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { connect } from "node:net"; import { dirname, join } from "node:path"; import type { FilePathWithPrefix, 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 { path2id_base } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; 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 { captureObsidianPage, 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; }; 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] [--migration]"); process.exit(0); } if (argument === "--migration") continue; 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 { 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 { 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((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 { 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 { if (!session) return; await session.app.stop(); context.activeSessions.delete(session); } async function stopSessions(context: RunnerContext): Promise { for (const session of [...context.activeSessions]) await stopSession(context, session); } async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { const target = join(vaultPath, path); await mkdir(dirname(target), { recursive: true }); await writeFile(target, content, "utf8"); } async function pathExists(path: string): Promise { 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 { 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 { try { let physicalPath = ""; for (const component of requestedPath.split("/")) { const names = await readdir(join(vaultPath, physicalPath)); const matches = names.filter((name) => name.normalize("NFC") === component.normalize("NFC")); if (matches.length > 1) throw new Error("Ambiguous physical names at " + join(physicalPath, component)); if (matches.length === 0) return { found: false, path: null, content: null }; physicalPath = join(physicalPath, matches[0]); } return { found: true, path: physicalPath, content: await readFile(join(vaultPath, physicalPath), "utf8") }; } 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 { 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 { return await evalObsidianJson( 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, documentId?: string ): Promise { return await evalObsidianJson( cliBinary, [ "(async()=>{", "const path=" + JSON.stringify(path) + ";", "const revision=" + JSON.stringify(revision ?? null) + ";", "const documentId=" + JSON.stringify(documentId ?? null) + ";", "const core=app.plugins.plugins['obsidian-livesync'].core;", "const entry=documentId?await core.localDatabase.getDBEntryFromMeta(await core.localDatabase.getRaw(documentId,revision?{rev:revision}:undefined),false,true):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 { 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 { 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 { 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 { 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 { 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 { 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 }); const peer = pane.locator(".peer-item").first(); await peer.waitFor({ state: "visible", timeout: uiTimeoutMs }); const allow = peer.getByRole("button", { name: "Allow in session", exact: true }); if (await allow.isVisible()) await allow.click({ timeout: uiTimeoutMs }); await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({ state: "visible", timeout: uiTimeoutMs, }); }); } async function replicateFromStatusPane(port: number): Promise { 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 { 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 { 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 { let finished = false; const acceptor = acceptConnectionRequests([sourcePort, receiverPort], () => finished); try { await replicateFromStatusPane(receiverPort); await waitForVaultContent(receiverVaultPath, expectedPath, expectedContent); } finally { finished = true; await acceptor; } } async function waitForTweakMismatchNotice(port: number, session: ObsidianLiveSyncSession): Promise { 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 seedLegacyNfdMetadata( context: RunnerContext, session: ObsidianLiveSyncSession, shared: SharedSettings, currentId: string ): Promise { // Obsidian's file cache normalises names even in legacy mode. Seed the // legacy ID of an NFD-writing host while retaining real Chunk documents. const legacyId = await path2id_base( pathNfd as FilePathWithPrefix, shared.encrypted ? shared.e2eePassphrase : false, true, shared.encrypted ? shared.idKey : undefined ); if (legacyId === currentId) throw new Error("The legacy NFD fixture did not change the Metadata ID."); await evalObsidianJson( context.cliBinary, [ "(async()=>{", "const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase.localDatabase;", "const original=await db.get(" + JSON.stringify(currentId) + ");", "const legacy={...original,_id:" + JSON.stringify(legacyId) + ",path:" + JSON.stringify(pathNfd) + "};", "delete legacy._rev;delete legacy._revisions;delete legacy._conflicts;", "await db.put(legacy);await db.remove(original);", "return JSON.stringify(true);", "})()", ].join(""), session.cliEnv ); const legacy = await readEntryState(context.cliBinary, session.cliEnv, pathNfd, undefined, legacyId); assertEqual(legacy.content, sourceContent, "The legacy NFD fixture lost its Chunk-backed body."); assertEqual(legacy.chunksPresent, true, "The legacy NFD fixture lost its Chunk documents."); return legacy; } async function startReconstruction( cliBinary: string, environment: NodeJS.ProcessEnv, operation: "rebuild" | "fetch", pathNormalizationVersion: 0 | 1 = 1 ): Promise { await evalObsidianJson( cliBinary, [ "(()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "globalThis.__p2pNfcReconstruction={done:false,error:null};", "void(async()=>{", "await core.services.setting.applyPartial({pathNormalizationVersion:" + pathNormalizationVersion + "});", "await core.services.setting.saveSettingData();", "if(core.settings.pathNormalizationVersion!==" + pathNormalizationVersion + ")throw new Error('The test did not apply the selected identity: '+core.settings.pathNormalizationVersion);", operation === "rebuild" ? "await core.rebuilder.$rebuildEverything();" : "await core.rebuilder.$fetchLocal(false,true);", "})().then(()=>{globalThis.__p2pNfcReconstruction.done=true;},error=>{globalThis.__p2pNfcReconstruction.error=String(error?.stack??error);});", "return JSON.stringify(true);", "})()", ].join(""), environment ); } async function waitForReconstruction(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { const deadline = Date.now() + syncTimeoutMs; while (Date.now() < deadline) { const state = await evalObsidianJson<{ done: boolean; error: string | null }>( cliBinary, "JSON.stringify(globalThis.__p2pNfcReconstruction)", environment ); if (state.error) throw new Error(state.error); if (state.done) return; await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error("The P2P reconstruction did not finish."); } async function waitForVaultAbsence(vaultPath: string, path: string): Promise { const deadline = Date.now() + syncTimeoutMs; while (Date.now() < deadline) { if (!(await readVaultFileState(vaultPath, path)).found) return; await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error("The retired path is still present: " + path); } async function verifyRenameAndDeletion( context: RunnerContext, sessionA: ObsidianLiveSyncSession, sessionB: ObsidianLiveSyncSession, vaultA: TemporaryVault, vaultB: TemporaryVault, portA: number, portB: number ): Promise { const oldPath = pathNfc.replace(/[^/]+$/, "a\u0301rbol.md").normalize("NFC"); const newPath = pathNfc.replace(/[^/]+$/, "renamed-e\u0301.md").normalize("NFC"); const content = "# Rename and deletion\n\nPreserve these exact bytes through the P2P rename.\n"; await evalObsidianJson( context.cliBinary, `(async()=>{const source=app.vault.getFiles().find(file=>file.path.normalize('NFC')===${JSON.stringify(pathNfc)});if(!source)throw new Error('Missing source note');await app.vault.create(source.path.replace(/[^/]+$/,'a\\u0301rbol.md'),${JSON.stringify(content)});return JSON.stringify(true);})()`, sessionA.cliEnv ); const before = await writeEntryStateWhenReady(context.cliBinary, sessionA.cliEnv, oldPath, content); await replicateAndWait(portA, portB, vaultB.path, oldPath, content); const received = await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, oldPath, content); assertEqual(received.id, before.id, "The new Unicode note has different peer IDs."); await evalObsidianJson( context.cliBinary, `(async()=>{const file=app.vault.getFiles().find(file=>file.path.normalize('NFC')===${JSON.stringify(oldPath)});if(!file)throw new Error('Missing rename source');await app.fileManager.renameFile(file,file.path.replace(/[^/]+$/,'renamed-e\\u0301.md'));return JSON.stringify(true);})()`, sessionB.cliEnv ); const renamed = await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, newPath, content); if (renamed.id === before.id) throw new Error("The cross-path rename retained the old Metadata ID."); await replicateAndWait(portB, portA, vaultA.path, newPath, content); const renamedAtSource = await writeEntryStateWhenReady(context.cliBinary, sessionA.cliEnv, newPath, content); assertEqual(renamedAtSource.id, renamed.id, "The renamed note has different peer IDs."); await waitForVaultAbsence(vaultA.path, oldPath); await waitForVaultAbsence(vaultB.path, oldPath); await evalObsidianJson( context.cliBinary, `(async()=>{const file=app.vault.getFiles().find(file=>file.path.normalize('NFC')===${JSON.stringify(newPath)});if(!file)throw new Error('Missing deletion source');await app.vault.delete(file);return JSON.stringify(true);})()`, sessionA.cliEnv ); let finished = false; const acceptor = acceptConnectionRequests([portA, portB], () => finished); try { const deadline = Date.now() + syncTimeoutMs; while ((await readVaultFileState(vaultB.path, newPath)).found && Date.now() < deadline) { await replicateFromStatusPane(portB); await new Promise((resolve) => setTimeout(resolve, 500)); } await waitForVaultAbsence(vaultA.path, newPath); await waitForVaultAbsence(vaultB.path, newPath); } finally { finished = true; await acceptor; } console.log("P2P NFC rename and deletion: canonical IDs, exact content, and both physical removals verified."); } async function runCase(definition: CaseDefinition, migration: boolean): Promise { 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 initialVersion = migration ? 0 : 1; const initialPath = migration ? pathNfd : pathNfc; const artifact = await createSetupArtifact(shared, initialVersion); const incompatibleArtifact = await createSetupArtifact(shared, 0); const captures: SetupCaptureNames = { scenario: "p2p-nfc-" + definition.name, guide: "p2p-setup", }; 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); if (migration) await writeVaultFile(vaultB.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, initialVersion); await waitForVaultContent(vaultA.path, pathNfc, sourceContent); let sourceEntry = await writeEntryStateWhenReady( context.cliBinary, sessionA.cliEnv, initialPath, 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, initialPath, { 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, initialVersion); await waitForVaultContent(vaultB.path, pathNfc, sourceContent); let receiverEntry = await writeEntryStateWhenReady( context.cliBinary, sessionB.cliEnv, initialPath, 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."); if (migration) { sourceEntry = await seedLegacyNfdMetadata(context, sessionA, shared, sourceEntry.id); receiverEntry = await seedLegacyNfdMetadata(context, sessionB, shared, receiverEntry.id); assertEqual(receiverEntry.id, sourceEntry.id, "The legacy NFD peers have different IDs."); const legacyId = sourceEntry.id; await assertSourceNameIsNfd(vaultB.path, sourceContent); await stopSession(context, sessionB); await startReconstruction(context.cliBinary, sessionA.cliEnv, "rebuild"); await acknowledgeDisabledOptionalFeatures(portA, captures); await waitForReconstruction(context.cliBinary, sessionA.cliEnv); sourceEntry = await writeEntryStateWhenReady(context.cliBinary, sessionA.cliEnv, pathNfc, sourceContent); if (sourceEntry.id === legacyId) throw new Error( "P2P Rebuild did not replace the legacy Metadata ID: " + JSON.stringify({ legacyId, sourceEntry, settings: await readCurrentSettings(context.cliBinary, sessionA.cliEnv, shared.idKey), }) ); assertEqual( (await readMetadataIds(context.cliBinary, sessionA.cliEnv)).ids.includes(legacyId), false, "The rebuilt source retained active legacy Metadata." ); await assertSourceNameIsNfd(vaultA.path, sourceContent); await stopSession(context, sessionA); sessionA = await startSession(context, vaultA, portA); await waitForLiveSyncCoreReady(context.cliBinary, sessionA.cliEnv); await assertCaseSettings(context.cliBinary, sessionA.cliEnv, definition, shared.idKey, 1); sessionB = await startSession(context, vaultB, portB); await waitForLiveSyncCoreReady(context.cliBinary, sessionB.cliEnv); await assertCaseSettings(context.cliBinary, sessionB.cliEnv, definition, shared.idKey, 0); await openP2PStatus(portA); await openP2PStatus(portB); await waitForDetectedPeer(portB); let finished = false; const acceptor = acceptConnectionRequests([portA, portB], () => finished); try { await replicateFromStatusPane(portB); await waitForTweakMismatchNotice(portB, sessionB); } finally { finished = true; await acceptor; } const refused = await readEntryState(context.cliBinary, sessionB.cliEnv, pathNfd, undefined, legacyId); assertEqual(refused.id, receiverEntry.id, "The refused legacy receiver changed its Metadata ID."); assertEqual(refused.rev, receiverEntry.rev, "The refused legacy receiver changed its revision."); assertEqual(refused.content, sourceContent, "The refused legacy receiver changed its content."); await withObsidianPage(portB, async (page) => { await page.locator(".notice").evaluateAll((notices) => notices.forEach((notice) => notice.remove())); }); await startReconstruction(context.cliBinary, sessionB.cliEnv, "fetch", 0); await withObsidianPage(portB, async (page) => { await modalByTitle(page, "P2P Rebuild").locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs, }); }); finished = false; const fetchAcceptor = acceptConnectionRequests([portA, portB], () => finished); try { await withObsidianPage(portB, async (page) => { await modalByTitle(page, "P2P Rebuild") .locator(".peer-item") .first() .getByRole("button", { name: "Sync", exact: true }) .click({ timeout: uiTimeoutMs }); }); await waitForTweakMismatchNotice(portB, sessionB); 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 }); } }); } finally { finished = true; await fetchAcceptor; } let fetchFailure = ""; try { await waitForReconstruction(context.cliBinary, sessionB.cliEnv); } catch (error) { fetchFailure = error instanceof Error ? error.message : String(error); } if (!fetchFailure.includes("The first Standard Fetch pass did not complete.")) { throw new Error("The incompatible P2P Fetch did not report its expected failure: " + fetchFailure); } const refusedFetchMetadata = await readMetadataIds(context.cliBinary, sessionB.cliEnv); assertEqual( refusedFetchMetadata.ids.includes(legacyId), false, "P2P Fetch did not replace the old local database before peer selection." ); assertEqual( refusedFetchMetadata.ids.includes(sourceEntry.id), false, "The incompatible Fetch imported source Metadata." ); await assertSourceNameIsNfd(vaultB.path, sourceContent); console.log( "P2P incompatible Fetch: local database reset preceded refusal; physical NFD note and exact bytes retained for retry." ); await startReconstruction(context.cliBinary, sessionB.cliEnv, "fetch"); await fetchFromFirstPeer(portA, portB); await waitForReconstruction(context.cliBinary, sessionB.cliEnv); await waitForLiveSyncCoreReady(context.cliBinary, sessionB.cliEnv); const migrated = await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, pathNfc, sourceContent); assertEqual(migrated.id, sourceEntry.id, "P2P Fetch did not converge to the rebuilt source ID."); assertEqual( (await readMetadataIds(context.cliBinary, sessionB.cliEnv)).ids.includes(legacyId), false, "The migrated receiver retained active legacy Metadata." ); await assertSourceNameIsNfd(vaultB.path, sourceContent); console.log( "P2P legacy-to-NFC migration: source Rebuild and restart, legacy refusal, receiver Fetch, matching IDs, exact content, and NFD physical names verified." ); } console.log("P2P NFC initial Fetch: matching IDs and exact content verified."); await reconnectP2PStatus(portA); await reconnectP2PStatus(portB); await waitForDetectedPeer(portA); 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); console.log("P2P NFC reverse transfer: stable ID and original revision verified."); 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(portA); 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); console.log("P2P NFC full restart: persisted data and another transfer verified."); await verifyRenameAndDeletion(context, sessionA, sessionB, vaultA, vaultB, portA, portB); 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." ); } catch (error) { for (const session of context.activeSessions) { const port = session.remoteDebuggingPort; await captureObsidianPage(port, `p2p-nfc-${definition.name}-${port}-failure.png`, (page) => page.locator("body").waitFor({ state: "visible" }) ).catch(() => undefined); const state = await withObsidianPage( port, async (page) => await page.locator(".workspace-leaf-content").allTextContents() ).catch(() => []); console.error(`P2P NFC failure UI on ${port}: ${JSON.stringify(state).slice(-6000)}`); } throw error; } 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 { const cases = selectedCases(process.argv.slice(2)); const migration = process.argv.includes("--migration"); for (const definition of cases) await runCase(definition, migration); } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });