mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-30 21:08:18 +00:00
Test P2P Fetch restart confirmation
This commit is contained in:
1 parent
8422c19881
commit
c4fc8f5118
2 files changed
+189
-2
No files matched your search
@@ -233,6 +233,10 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
|
|||||||
|
|
||||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||||
|
|
||||||
|
On headless Linux, run this two-device scenario without an inherited display, for example `env -u DISPLAY -u WAYLAND_DISPLAY npm run test:e2e:obsidian:p2p-setup-uri-workflow`. The shared launcher then gives each Obsidian session its own Xvfb display. Wrapping both sessions in one outer `xvfb-run` can hide the source window, trigger its normal suspension lifecycle, and close the P2P connection before the other device fetches.
|
||||||
|
|
||||||
|
The P2P Setup URI workflow also schedules another Fetch and injects an exception after the real P2P receive finishes, before the Vault scan. It verifies that the received note remains unreflected and both reflection directions stay suspended, then reloads Obsidian and confirms the current P2P remote and saved choices. The resumed Fetch must retrieve the exact source content, preserve a local-only note created during the interruption, remove both fetch flags, and resume both reflection directions. This checks application reload after an injected failure; it does not simulate a network outage, operating-system termination, or power loss.
|
||||||
|
|
||||||
`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-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: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: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.
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ async function fetchFromFirstPeer(
|
|||||||
sessionA: ObsidianLiveSyncSession,
|
sessionA: ObsidianLiveSyncSession,
|
||||||
portA: number,
|
portA: number,
|
||||||
portB: number,
|
portB: number,
|
||||||
screenshots: string[]
|
screenshots: string[],
|
||||||
|
selectionScreenshot = "guide-p2p-setup-select-first-device.png"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await withObsidianPage(portB, async (page) => {
|
await withObsidianPage(portB, async (page) => {
|
||||||
@@ -363,7 +364,7 @@ async function fetchFromFirstPeer(
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild"));
|
screenshots.push(await captureGuideDialogue(portB, selectionScreenshot, "P2P Rebuild"));
|
||||||
|
|
||||||
let finished = false;
|
let finished = false;
|
||||||
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
|
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
|
||||||
@@ -390,6 +391,186 @@ async function fetchFromFirstPeer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyInterruptedFetchRestart(
|
||||||
|
context: RunnerContext,
|
||||||
|
sessionA: ObsidianLiveSyncSession,
|
||||||
|
sessionB: ObsidianLiveSyncSession,
|
||||||
|
vaultA: TemporaryVault,
|
||||||
|
vaultB: TemporaryVault,
|
||||||
|
screenshots: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const portA = sessionA.remoteDebuggingPort;
|
||||||
|
const portB = sessionB.remoteDebuggingPort;
|
||||||
|
const remotePath = "E2E/p2p/fetched-after-restart.md";
|
||||||
|
const remoteContent = "# P2P recovery source\n\nFetch these exact bytes after confirming the saved choices.\n";
|
||||||
|
const localPath = "E2E/p2p/local-only-during-recovery.md";
|
||||||
|
const localContent = "# Local recovery note\n\nKeep these exact bytes while restarting the P2P Fetch.\n";
|
||||||
|
await writeNote(context.cliBinary, sessionA.cliEnv, remotePath, remoteContent);
|
||||||
|
|
||||||
|
await withObsidianPage(portB, async (page) => {
|
||||||
|
const before = await page.evaluate(async (path) => {
|
||||||
|
const app = (globalThis as any).app;
|
||||||
|
const core = app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
return {
|
||||||
|
inVault: await app.vault.adapter.exists(path),
|
||||||
|
inDatabase: !!(await core.localDatabase.getDBEntry(path, undefined, false, true)),
|
||||||
|
};
|
||||||
|
}, remotePath);
|
||||||
|
assertEqual(before.inVault, false, "The recovery source note already existed in the receiving Vault.");
|
||||||
|
assertEqual(before.inDatabase, false, "The recovery source note arrived before the scheduled P2P Fetch.");
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
if (!(await core.rebuilder.scheduleFetch())) throw new Error("Could not schedule the P2P Fetch.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await withObsidianPage(portB, async (page) => {
|
||||||
|
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
const fetch = core.rebuilder.$fetchLocalDBFast.bind(core.rebuilder);
|
||||||
|
// Keep the actual P2P transport and interrupt before the subsequent Vault scan.
|
||||||
|
core.rebuilder.$fetchLocalDBFast = async (...args: unknown[]) => {
|
||||||
|
await fetch(...args);
|
||||||
|
(globalThis as any).__p2pFetchInterrupted = true;
|
||||||
|
throw new Error("E2E: interrupted after P2P receive and before Vault reflection");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
screenshots.push(...(await confirmFastFetch(portB, { scenario: "p2p-fetch-restart", guide: "p2p-fetch-restart" })));
|
||||||
|
await fetchFromFirstPeer(sessionA, portA, portB, screenshots, "p2p-fetch-interrupted-select-source.png");
|
||||||
|
|
||||||
|
const pending = await withObsidianPage(portB, async (page) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
const settings = core.services.setting.currentSettings();
|
||||||
|
return (
|
||||||
|
(globalThis as any).__p2pFetchInterrupted === true &&
|
||||||
|
settings.suspendFileWatching &&
|
||||||
|
settings.suspendParseReplicationResult
|
||||||
|
);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: uiTimeoutMs }
|
||||||
|
);
|
||||||
|
return await page.evaluate(
|
||||||
|
async ({ remotePath, localPath, localContent }) => {
|
||||||
|
const app = (globalThis as any).app;
|
||||||
|
const core = app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
const remoteEntry = await core.localDatabase.getDBEntry(remotePath, undefined, false, true);
|
||||||
|
const remoteInVault = await app.vault.adapter.exists(remotePath);
|
||||||
|
await app.vault.create(localPath, localContent);
|
||||||
|
return {
|
||||||
|
received: !!remoteEntry,
|
||||||
|
remoteInVault,
|
||||||
|
markdown: await app.vault.adapter.read("flag_fetch.md"),
|
||||||
|
room: core.services.setting.currentSettings().P2P_roomID,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ remotePath, localPath, localContent }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assertEqual(pending.received, true, "The interruption occurred before the real P2P receive completed.");
|
||||||
|
assertEqual(pending.remoteInVault, false, "The interrupted P2P Fetch reflected the source note too early.");
|
||||||
|
const block = /```json\s*\n([\s\S]*?)\n```/.exec(pending.markdown);
|
||||||
|
if (!block) throw new Error("The interrupted P2P Fetch did not retain its choices in the flag.");
|
||||||
|
const selection = JSON.parse(block[1]);
|
||||||
|
assertEqual(selection.stage1, "Overwrite all with remote files", "The P2P overwrite choice was not retained.");
|
||||||
|
assertEqual(
|
||||||
|
selection.stage2,
|
||||||
|
"Keep local files even if not on remote",
|
||||||
|
"The P2P preservation choice was not retained."
|
||||||
|
);
|
||||||
|
console.log("P2P workflow: received the recovery note, interrupted before reflection, and retained the choices.");
|
||||||
|
|
||||||
|
await withObsidianPage(portB, async (page) => {
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(globalThis as any).app.plugins.plugins["obsidian-livesync"].core.services.appLifecycle.performRestart()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await withObsidianPage(portB, async (page) => {
|
||||||
|
const modal = modalByTitle(page, "Resume scheduled fetch?");
|
||||||
|
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||||
|
const message = await modal.innerText();
|
||||||
|
for (const expected of ["Current remote:", "P2P", pending.room, selection.stage1, selection.stage2]) {
|
||||||
|
if (!message.includes(expected))
|
||||||
|
throw new Error("The P2P resume dialogue omitted the remote or saved choices.");
|
||||||
|
}
|
||||||
|
const beforeConfirmation = await page.evaluate(async (remotePath) => {
|
||||||
|
const app = (globalThis as any).app;
|
||||||
|
const core = app.plugins.plugins["obsidian-livesync"].core;
|
||||||
|
const settings = core.services.setting.currentSettings();
|
||||||
|
return {
|
||||||
|
reloaded: (globalThis as any).__p2pFetchInterrupted === undefined,
|
||||||
|
remoteInVault: await app.vault.adapter.exists(remotePath),
|
||||||
|
markdown: await app.vault.adapter.read("flag_fetch.md"),
|
||||||
|
suspendFileWatching: settings.suspendFileWatching,
|
||||||
|
suspendParseReplicationResult: settings.suspendParseReplicationResult,
|
||||||
|
};
|
||||||
|
}, remotePath);
|
||||||
|
assertEqual(beforeConfirmation.reloaded, true, "Obsidian did not reload before the recovery confirmation.");
|
||||||
|
assertEqual(beforeConfirmation.remoteInVault, false, "The source note was reflected before confirmation.");
|
||||||
|
assertEqual(beforeConfirmation.markdown, pending.markdown, "Restart changed the retained P2P choices.");
|
||||||
|
assertEqual(beforeConfirmation.suspendFileWatching, true, "File watching resumed before confirmation.");
|
||||||
|
assertEqual(
|
||||||
|
beforeConfirmation.suspendParseReplicationResult,
|
||||||
|
true,
|
||||||
|
"Database reflection resumed before confirmation."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
screenshots.push(await captureGuideDialogue(portB, "p2p-fetch-resume-confirmation.png", "Resume scheduled fetch?"));
|
||||||
|
await withObsidianPage(portB, async (page) => {
|
||||||
|
await modalByTitle(page, "Resume scheduled fetch?")
|
||||||
|
.getByRole("button", { name: "Continue with these choices", exact: true })
|
||||||
|
.click({ timeout: uiTimeoutMs });
|
||||||
|
});
|
||||||
|
await fetchFromFirstPeer(sessionA, portA, portB, screenshots, "p2p-fetch-resumed-select-source.png");
|
||||||
|
await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
|
||||||
|
await waitForPathContent(vaultB, remotePath, remoteContent);
|
||||||
|
await waitForPathContent(vaultB, localPath, localContent);
|
||||||
|
await waitForPathContent(vaultA, remotePath, remoteContent);
|
||||||
|
const completed = await withObsidianPage(
|
||||||
|
portB,
|
||||||
|
async (page) =>
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const app = (globalThis as any).app;
|
||||||
|
const settings = app.plugins.plugins["obsidian-livesync"].core.services.setting.currentSettings();
|
||||||
|
return {
|
||||||
|
flags: await Promise.all(
|
||||||
|
["flag_fetch.md", "redflag3.md"].map((path) => app.vault.adapter.exists(path))
|
||||||
|
),
|
||||||
|
suspendFileWatching: settings.suspendFileWatching,
|
||||||
|
suspendParseReplicationResult: settings.suspendParseReplicationResult,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assertEqual(completed.flags.some(Boolean), false, "The resumed P2P Fetch retained a fetch flag.");
|
||||||
|
assertEqual(completed.suspendFileWatching, false, "File watching remained suspended after P2P recovery.");
|
||||||
|
assertEqual(
|
||||||
|
completed.suspendParseReplicationResult,
|
||||||
|
false,
|
||||||
|
"Database reflection remained suspended after P2P recovery."
|
||||||
|
);
|
||||||
|
const sourceHasLocalNote = await withObsidianPage(
|
||||||
|
portA,
|
||||||
|
async (page) =>
|
||||||
|
await page.evaluate(async (path) => (globalThis as any).app.vault.adapter.exists(path), localPath)
|
||||||
|
);
|
||||||
|
assertEqual(sourceHasLocalNote, false, "The recovery note was not local-only during the Fetch.");
|
||||||
|
console.log(
|
||||||
|
"P2P_FETCH_RESTART " +
|
||||||
|
JSON.stringify({
|
||||||
|
realReceiveBeforeInterruption: true,
|
||||||
|
promptedAfterReload: true,
|
||||||
|
remoteContentPreserved: true,
|
||||||
|
localOnlyContentPreserved: true,
|
||||||
|
flagsRemoved: true,
|
||||||
|
reflectionResumed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function replicateFromStatusPane(port: number): Promise<void> {
|
async function replicateFromStatusPane(port: number): Promise<void> {
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||||
@@ -539,6 +720,8 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
console.log("P2P workflow: initial Fetch from the first device completed.");
|
console.log("P2P workflow: initial Fetch from the first device completed.");
|
||||||
|
|
||||||
|
await verifyInterruptedFetchRestart(context, sessionA, sessionB, vaultA, vaultB, screenshots);
|
||||||
|
|
||||||
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
|
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
|
||||||
await reconnectP2PStatus(portA);
|
await reconnectP2PStatus(portA);
|
||||||
await reconnectP2PStatus(portB);
|
await reconnectP2PStatus(portB);
|
||||||
|
|||||||
Reference in new issue
Block a user